StageLedger · x402
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.
The primitive.
The onchain primitive runs at the right moment in the flow and surfaces a clear, verifiable result that theater companies can act on without web3 jargon.
Why this primitiveTheater 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.
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 "StageLedger" in ONE Lovable message. Single-page x402-native paid app on Ethereum Sepolia.
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 Ethereum Sepolia (native Circle USDC via EIP-3009). 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 flows beyond the Privy drop-in.
- NO smart contract deploy. x402 settles USDC via EIP-3009 `transferWithAuthorization` on the existing
Circle USDC contract 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 (Ethereum Sepolia, chainId 11155111).
- Self-host the facilitator as ONE TanStack server route inside the same app (shape below).
- At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea).
- Skip tests, skip CI, skip docs. Ship the demo.
SEPOLIA UX GUARDRAILS (session learnings — apply on top of the x402 rules above)
- Privy sponsored host-chain sends: any user tx (including EIP-3009 authorization broadcast on
the fallback path) MUST be sent as:
sendTransaction({ to, data, chainId: 11155111 }, { sponsor: true, address: embedded.address });
Both `sponsor: true` and `address: embedded.address` are required or Privy silently routes
through a non-embedded wallet and prompts for gas. NEVER pass uiOptions:{ showWalletUIs: false }
— it aborts with "signal is aborted without reason".
- Decimal USDC UX (non-negotiable): inputs are <input inputMode="decimal" defaultValue="0.01">.
Parse with parseUnits(str.trim(), 6) → bigint atomic. Display every balance / amount as
`${formatUnits(wei, 6)} USDC`. NEVER render raw atomic values like "10000" or "USDC (micro)"
labels — users have called this out as confusing.
- Empty-state UX when wallet USDC balance is 0: render an amber notice containing
(a) the exact wallet address to fund, (b) a "Copy address" button, (c) a link to the Circle
Sepolia USDC faucet https://faucet.circle.com/, (d) a "Refresh balance" button. Do NOT let
the primary "Pay $0.01" action be clickable while balance is 0 — it will revert / 402-loop.
- Facilitator settlement UI: when the facilitator returns a settled tx hash, render a clickable
Sepolia Etherscan link built server-side (`https://sepolia.etherscan.io/tx/${txHash}`), not
reconstructed client-side. Surface raw facilitator errors (invalid_payload, insufficient_funds,
invalid_signature) verbatim in the step log — do NOT swallow them.
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 { sepolia } from 'viem/chains';
<PrivyProvider appId={import.meta.env.VITE_PRIVY_APP_ID}
config={{ loginMethods:['google','email','wallet'],
embeddedWallets:{ ethereum:{ createOnLogin:'users-without-wallets' } },
defaultChain: sepolia,
supportedChains: [sepolia] }}>
- viem public client uses Alchemy Sepolia. Put it in src/data/rpc.json as
{ "sepolia": "https://eth-sepolia.g.alchemy.com/v2/<KEY>" }
and use createPublicClient({ chain: sepolia, transport: http(rpc.sepolia) }).
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:11155111" (NOT "ethereum-sepolia" or "sepolia"). 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. Sepolia USDC 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 Ethereum Sepolia BEFORE reading eth_accounts. Call
provider.request({ method: "wallet_switchEthereumChain", params: [{ chainId: "0xaa36a7" }] }).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 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: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238",
payTo: "<treasury or relayer EOA>",
chainId: 11155111,
network: "eip155:11155111",
networkName: "Ethereum Sepolia",
amount: "10000",
faucetUrl: "https://faucet.circle.com/",
ethFaucetUrl: "https://cloud.google.com/application/web3/faucet/ethereum/sepolia",
explorer: "https://sepolia.etherscan.io" }
src/data/rpc.json { "sepolia": "https://eth-sepolia.g.alchemy.com/v2/<KEY>" }
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";
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:"0xaa36a7" }] }).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 Ethereum Sepolia)
- ETH: https://cloud.google.com/application/web3/faucet/ethereum/sepolia
→ "Refresh balance" reads USDC balanceOf via viem + Alchemy RPC.
3. Primary action for this idea (production agreements). App runs:
(a) Challenge — GET /api/public/x402-paid-content → expect 402 → parse
{ x402Version:2, accepts:[…] }. Pick where network==="eip155:11155111" && 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://cloud.google.com/application/web3/faucet/ethereum/sepolia
Same address goes in x402.json's payTo so the treasury receives the USDC.
- SEPOLIA_RPC_URL (+ VITE_SEPOLIA_RPC_URL) Free HTTPS URL from https://dashboard.alchemy.com/ (create app,
Ethereum 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).
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.