ScriptStream · x402
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.
The primitive.
The onchain primitive runs at the right moment in the flow and surfaces a clear, verifiable result that writers can act on without web3 jargon.
Why this primitiveBy 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.
Required keys.
Add these in your Lovable project under Settings → Secrets before pasting the prompt below.
The build prompt.
Paste into a fresh Lovable project. Make sure all five secrets above are set first. read the build strategy →
Build "ScriptStream" in ONE Lovable message. Single-page x402-native paid app on BOB Sepolia.
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 BOB Sepolia (native Circle USDC via EIP-3009). 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.
NETWORK — BOB Sepolia (testnet, required verbatim)
- chainId 808813 (hex 0xc57cd). BOB is an OP-Stack L2 secured by Bitcoin.
- Gas token is ETH. Never BTC, and never the BOB ERC-20 token — holding BOB on
Ethereum L1 does not pay for gas on the BOB network.
- RPC: https://bob-sepolia.rpc.gobob.xyz
- Explorer: https://bob-sepolia.explorer.gobob.xyz (Blockscout, not Etherscan)
- Funding: claim Sepolia ETH at https://sepolia-faucet.pk910.de/ then bridge to
BOB Sepolia at https://bob-sepolia.gobob.xyz/
- Reference: https://docs.gobob.xyz/docs/user-hub/networks
- CAIP-2 network id for the x402 envelope: eip155:808813. Amounts are atomic
with 6 decimals, so "10000" = 0.01 USDC.
- Blockscout never auto-verifies: run `npx hardhat verify --network bobSepolia <address>`
right after deploy, keeping a dummy `apiKey: { bobSepolia: "empty" }` entry.
5-CREDIT BUDGET (HARD LIMIT)
- ONE single-page app. No Lovable Cloud, no database, no auth flows beyond the Privy drop-in.
- DEPLOY ONE MockUSDC (EIP-3009 compliant, name "USDC", version "2", 6 decimals) to BOB Sepolia via Hardhat, then
seed the demo wallet with balance in the constructor. BOB Sepolia has no canonical Circle USDC — you own the token.
Facilitator settles `transferWithAuthorization` against YOUR deployed MockUSDC address (chainId 808813).
- Self-host the facilitator as ONE TanStack server route inside the same app (shape below).
- At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea).
- Skip tests, skip CI, skip docs. Ship the demo.
STACK
- React + Vite + TanStack Start (the template Lovable ships).
- Privy wraps <App />. The signing wallet MUST be an external EOA (MetaMask), NOT a Privy embedded wallet — see rule 1.
import { bobSepolia } from '@/lib/chains'; // custom viem Chain: id 808813, rpc https://bob-sepolia.rpc.gobob.xyz, explorer https://bob-sepolia.explorer.gobob.xyz
<PrivyProvider appId={import.meta.env.VITE_PRIVY_APP_ID}
config={{ loginMethods:['google','email','wallet'],
embeddedWallets:{ ethereum:{ createOnLogin:'users-without-wallets' } },
defaultChain: bobSepolia,
supportedChains: [bobSepolia] }}>
- viem public client uses the BOB Sepolia RPC. Put it in src/data/rpc.json as
{ "bobSepolia": "https://bob-sepolia.rpc.gobob.xyz" }
and use createPublicClient({ chain: bobSepolia, transport: http(rpc.bobSepolia) }).
The default public RPC rate-limits balance reads and over-estimates gas.
THIRTEEN NON-OBVIOUS RULES (get any of these wrong and the demo silently fails)
1. DO NOT sign x402 with the Privy embedded wallet. Privy embeds are EIP-7702 delegated EOAs — the USDC v2.2
SignatureChecker sees on-chain code at the signer address, routes to ERC-1271 isValidSignature, and reverts
with "FiatTokenV2: invalid signature" (payer_is_smart_account). x402 signing MUST come from an external EOA
(MetaMask). Connect it via Privy's "wallet" login method after Google/email sign-in.
2. Self-host the facilitator as ONE route. GET /api/public/x402-paid-content:
- no PAYMENT-SIGNATURE header → return 402 with { x402Version:2, accepts:[PaymentRequirement] }.
- with header → decode envelope, verify EIP-3009, simulate, writeContract, wait,
return 200 + PAYMENT-RESPONSE header (base64 JSON with
{ success, transaction, network, payer }).
The /api/public/* prefix bypasses Lovable's published-site auth — that's intentional for the demo.
3. x402 v2 envelope shape (NOT v1's { scheme, network, payload } at top level — facilitator rejects that as
invalid_payload). It MUST be:
{ "x402Version": 2,
"accepted": { /* echo the full PaymentRequirement you picked, verbatim */ },
"payload": { "signature": "0x…",
"authorization": { from, to, value, validAfter, validBefore, nonce } } }
4. Network id is CAIP-2: "eip155:808813" (NOT "bob-sepolia" or "bobSepolia"). Match on this when picking
a requirement from accepts[]. Scheme is "exact".
5. Amount is atomic units, string. USDC has 6 decimals — "10000" = 0.01 USDC. Field name is `amount` (v2),
NOT v1's maxAmountRequired.
6. Header names are literal-cased and non-standard: PAYMENT-SIGNATURE (request) and PAYMENT-RESPONSE (response).
Read case-insensitively (fetch Headers is), but SEND exactly that casing.
7. Read the EIP-712 domain from the USDC contract via EIP-5267 — never hardcode name/version. Call
eip712Domain() on the token (falls back to name() + version()), cache by (chainId, asset). Circle rotates
versions across chains. Circle USDC on peer chains currently returns ("USDC", "2") but treat that as data, not truth.
Put the on-chain values into the requirement's `extra: { name, version }` and thread them into the EIP-712
domain used for signing AND for server-side recovery.
8. MetaMask eth_signTypedData_v4 REQUIRES "EIP712Domain" in the payload types. If you omit it, MetaMask hashes
a different digest than viem's recoverTypedDataAddress — you get signer_mismatch on every attempt. Include:
types: {
EIP712Domain: [
{ name: "name", type: "string" },
{ name: "version", type: "string" },
{ name: "chainId", type: "uint256" },
{ name: "verifyingContract", type: "address" },
],
TransferWithAuthorization: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "value", type: "uint256" },
{ name: "validAfter", type: "uint256" },
{ name: "validBefore", type: "uint256" },
{ name: "nonce", type: "bytes32" },
],
}
Only pass EIP712Domain in the RPC payload sent to the wallet. Do NOT include it in the types object
you pass to viem's recoverTypedDataAddress — viem adds it itself.
9. Recover the signer BROWSER-SIDE before submitting. Right after eth_signTypedData_v4 returns, call viem's
recoverTypedDataAddress on the same signature and check it matches
provider.request({ method: "eth_accounts" })[0]. If not, show "select one account in MetaMask, reconnect,
and retry". Thread authorization.from = recovered into the envelope so the server and MetaMask agree.
10. Force chain switch to BOB Sepolia BEFORE reading eth_accounts. Call
provider.request({ method: "wallet_switchEthereumChain", params: [{ chainId: "0xc576d" }] }).catch(()=>{}).
A wallet on the wrong chain will still sign, but the domain digest differs — recovery fails.
11. nonce is 32 random bytes generated client-side (crypto.getRandomValues(new Uint8Array(32)) → 0x-hex). Never
reuse. validAfter = now - 60s, validBefore = now + (requirement.maxTimeoutSeconds ?? 300).
12. Server-side, before writeContract, ALWAYS do:
(a) code = pub.getCode({ address: auth.from }); reject "unsupported_payer" if code !== "0x"
(protects against a stray Privy embedded / smart account signature slipping through).
(b) recovered = recoverTypedDataAddress(...); reject "signer_mismatch" if != auth.from.
(c) authorizationState(from, nonce) === false and balanceOf(from) >= amount.
(d) simulateContract(transferWithAuthorization) — if it throws, format the revert reason (regex out
"reverted with the following reason: '...'") and return 402 with settle_preflight_failed.
13. Broadcast with PINNED gas: writeContract({ ..., gas: 250_000n }). Public BOB Sepolia RPC over-estimates
intrinsic gas — you'll see "intrinsic gas too high" without a pin. After broadcast,
receipt = waitForTransactionReceipt; check receipt.status === "success". Inclusion ≠ success — a revert
still costs the relayer gas but should be surfaced as tx_reverted with the tx hash. On revert, re-run
simulateContract and format the reason for the flow log.
FILE LAYOUT
src/data/x402.json { endpoint: "/api/public/x402-paid-content",
proxy: "/api/public/x402-paid-content",
usdcAddress: "<address of your deployed MockUSDC on BOB Sepolia>",
payTo: "<treasury or relayer EOA>",
chainId: 808813,
network: "eip155:808813",
networkName: "BOB Sepolia",
amount: "10000",
faucetUrl: "https://bob-sepolia.gobob.xyz/",
ethFaucetUrl: "https://bob-sepolia.gobob.xyz/",
explorer: "https://bob-sepolia.explorer.gobob.xyz" }
src/data/rpc.json { "bobSepolia": "https://bob-sepolia.rpc.gobob.xyz" }
src/lib/x402.ts fetchChallenge / pickRequirement / signPayment / fetchPaid
src/routes/api/public/x402-paid-content.ts self-hosted facilitator (challenge + verify + settle)
src/routes/index.tsx demo UI: sign-in → connect MetaMask → fund → 4-step flow log
FACILITATOR ROUTE (drop-in — the shape you must ship)
```ts
// src/routes/api/public/x402-paid-content.ts
import { createFileRoute } from "@tanstack/react-router";
import x402Cfg from "@/data/x402.json";
import {
createPublicClient, createWalletClient, http, parseAbi,
parseSignature, recoverTypedDataAddress, type Hex,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { sepolia } from "viem/chains"; import { bobSepolia } from "@/lib/chains";
const ABI = parseAbi([
"function transferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce,uint8 v,bytes32 r,bytes32 s)",
"function authorizationState(address,bytes32) view returns (bool)",
"function balanceOf(address) view returns (uint256)",
"function eip712Domain() view returns (bytes1,string,string,uint256,address,bytes32,uint256[])",
"function name() view returns (string)",
"function version() view returns (string)",
]);
const CORS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, PAYMENT-SIGNATURE",
"Access-Control-Expose-Headers": "PAYMENT-RESPONSE, PAYMENT-REQUIRED",
};
// Follow rules 2–13 above inside the GET handler. Return 402 with accepts[] when no header;
// otherwise decode → checks (12a–d) → writeContract({...,gas:250_000n}) → waitForTransactionReceipt
// → 200 with PAYMENT-RESPONSE header on success, 402 with settle_reverted otherwise.
export const Route = createFileRoute("/api/public/x402-paid-content")({
server: { handlers: { OPTIONS: async () => new Response(null,{status:204,headers:CORS}),
GET: async ({ request }) => handle(request) } },
});
```
SIGNING BRIDGE (drop-in — MetaMask via Privy)
```ts
const provider = await selectedWallet.getEthereumProvider();
await provider.request({ method:"wallet_switchEthereumChain",
params:[{ chainId:"0xc576d" }] }).catch(()=>{});
const [active] = await provider.request({ method:"eth_accounts" });
const from = getAddress(active);
const finalized = { ...typedData, message: { ...typedData.message, from } };
const rpcPayload = { ...finalized, types: { EIP712Domain: [...], ...finalized.types } }; // rule 8
const signature = await provider.request({ method:"eth_signTypedData_v4",
params:[from, JSON.stringify(rpcPayload)] });
const recovered = await recoverTypedDataAddress({ ...finalized, signature });
if (recovered.toLowerCase() !== from.toLowerCase())
throw new Error("Select one account in MetaMask, reconnect, and retry.");
return { signature, from, message: finalized.message };
```
USER FLOW (log every step in the UI)
1. Land on page → "Sign in" (Privy Google/email) → "Connect wallet" → MetaMask.
2. Fund: show MetaMask address + two faucet links —
- USDC: https://faucet.circle.com/ (choose BOB Sepolia)
- ETH: https://bob-sepolia.gobob.xyz/
→ "Refresh balance" reads USDC balanceOf via viem + Alchemy RPC.
3. Primary action for this idea (scriptwriting dialogue). App runs:
(a) Challenge — GET /api/public/x402-paid-content → expect 402 → parse
{ x402Version:2, accepts:[…] }. Pick where network==="eip155:808813" && scheme==="exact".
(b) Sign — Build EIP-3009 typed data (rules 7, 8), sign via MetaMask through the bridge above,
wrap into the v2 envelope (rule 3), base64.
(c) Retry — GET /api/public/x402-paid-content with header PAYMENT-SIGNATURE: <base64>.
(d) Settle — On 200, read PAYMENT-RESPONSE header, base64-decode → { success, transaction, network, payer }.
Link tx to `${explorer}/tx/${transaction}`.
4. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14"
REQUIRED SECRETS (Lovable → Project Settings → Secrets)
- PRIVY_APP_ID (+ VITE_PRIVY_APP_ID) Google/email login + external wallet connect. Docs: https://docs.privy.io/llms-full.txt
- RELAYER_PRIVATE_KEY Funded EOA that pays ETH gas for transferWithAuthorization.
Fund at https://bob-sepolia.gobob.xyz/
Same address goes in x402.json's payTo so the treasury receives the USDC.
- BOB_SEPOLIA_RPC_URL (+ VITE_BOB_SEPOLIA_RPC_URL) Free HTTPS URL from https://dashboard.alchemy.com/ (create app,
BOB Sepolia). Public RPC rate-limits and over-estimates gas.
FAILURE-MODE TABLE (fix these before shipping)
- "TypeError: Failed to fetch" → You're calling a third-party facilitator, not the same-origin route. Use /api/public/x402-paid-content.
- "intrinsic gas too high" → No gas pin on writeContract. Set gas: 250_000n.
- "invalid_signature: signer_mismatch" → Missing EIP712Domain in the RPC payload (rule 8), or MetaMask signed with a different account than eth_accounts[0] (rule 9).
- "payer_is_smart_account" / isValidSignature revert → You signed with the Privy embedded wallet. Connect MetaMask instead (rule 1).
- "settle_reverted: FiatTokenV2: invalid signature" → Domain name/version hardcoded, doesn't match on-chain. Read via EIP-5267 (rule 7).
- "insufficient_funds" → Wallet has ETH but no USDC. Hit the Circle faucet, then Refresh balance.
- "nonce_already_used" / expires_at errors → Reused envelope or clock skew. Regenerate nonce + timestamps per attempt (rule 11).
- Balance stuck at 0 after Circle faucet → Reading via default public RPC. Wire Alchemy in rpc.json.
- "invalid_payload" → Sent v1 envelope shape. Wrap under `accepted` (rule 3).
DESIGN DIRECTION
Commit to one distinctive look drawn from writing, poetry & narrative — specifically scriptwriting dialogue.
No purple-on-white gradient hero, no Inter/Poppins default stack, no generic three-card
feature row. Pick a real typeface pairing, one accent colour plus a near-monochrome base,
and a layout with an obvious focal point. Define colours as CSS variables/tokens; never
hardcode text-white or bg-black. Dark by default. The proof elements (payer address, USDC
balance, settlement tx hash) should read as deliberate mono-type detail, not debug output.
CREDIT (must appear in UI footer):
Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
Market sizing.
Indicative figures for hackathon pitches — refine with your own research before raising.