GoldenRatio · x402
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.
The primitive.
The onchain primitive runs at the right moment in the flow and surfaces a clear, verifiable result that photographers can act on without web3 jargon.
Why this primitiveBy 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.
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 "GoldenRatio" in ONE Lovable message. Single-page x402-native paid app on Optimism Sepolia.
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 Optimism Sepolia (USDC via EIP-3009). 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 flows beyond the Privy drop-in.
- NO smart contract deploy. x402 settles USDC via EIP-3009 `transferWithAuthorization` on the existing
Circle USDC contract 0x5fd84259d66Cd46123540766Be93DFE6D43130D7 (Optimism Sepolia, chainId 11155420).
- Self-host the facilitator as ONE TanStack server route inside the same app (code below).
- At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if 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 />. Wallet MUST be an external EOA (MetaMask), NOT a Privy embedded wallet — see rule 1.
<PrivyProvider appId={import.meta.env.VITE_PRIVY_APP_ID}
config={{ loginMethods:['google','email','wallet'],
embeddedWallets:{ ethereum:{ createOnLogin:'users-without-wallets' } },
defaultChain: optimismSepolia,
supportedChains: [optimismSepolia] }}>
Import optimismSepolia from 'viem/chains'. In the Privy dashboard, DISABLE "Smart Wallets" for embedded.
- viem public client uses Alchemy Optimism Sepolia. Put it in src/data/rpc.json as
{ "optimismSepolia": "https://opt-sepolia.g.alchemy.com/v2/<KEY>" }
and use createPublicClient({ chain: optimismSepolia, transport: http(rpc.optimismSepolia) }).
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. If you also need internal contract writes (event
logs etc.), sponsor those separately on the embedded wallet.
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] }
and a PAYMENT-REQUIRED response header (same body, base64).
- 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:11155420" (NOT "optimism-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 them case-insensitively (fetch's 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 and redeploys. OP Sepolia USDC currently returns ("USD Coin", "2") but that can change.
Put the on-chain values into the requirement's `extra: { name, version }` field 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" },
],
}
NOTE: 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 and will double-count if you do.
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" — the user has
multiple accounts and one signed while another is active. Thread authorization.from = recovered into the
envelope so the server and MetaMask agree on who signed.
10. Force chain switch to OP Sepolia BEFORE reading eth_accounts. Call
provider.request({ method: "wallet_switchEthereumChain", params: [{ chainId: "0xaa37dc" }] }).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 with "unsupported_payer" if code !== "0x"
(protects against a stray Privy embedded / smart account signature slipping through).
(b) recovered = recoverTypedDataAddress(...); reject with "signer_mismatch" if != auth.from.
(c) authorizationState(from, nonce) === false and balanceOf(from) >= amount.
(d) simulateContract(transferWithAuthorization) — if this throws, format the on-chain 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 OP Sepolia RPC over-estimates
intrinsic gas above the block limit — 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 to the user as tx_reverted with the tx hash. On revert,
re-run simulateContract and format the revert reason for the flow log.
FILE LAYOUT
src/data/x402.json { endpoint: "/api/public/x402-paid-content",
proxy: "/api/public/x402-paid-content",
usdcAddress: "0x5fd84259d66Cd46123540766Be93DFE6D43130D7",
payTo: "<treasury or relayer EOA>",
chainId: 11155420,
network: "eip155:11155420",
networkName: "Optimism Sepolia",
amount: "10000",
faucetUrl: "https://faucet.circle.com/",
ethFaucetUrl: "https://console.optimism.io/faucet",
explorer: "https://sepolia-optimism.etherscan.io" }
src/data/rpc.json { "optimismSepolia": "https://opt-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: 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 { optimismSepolia } 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:"0xaa37dc" }] }).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 }; // envelope uses recovered as authorization.from
```
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 Optimism Sepolia)
- ETH: https://console.optimism.io/faucet
→ "Refresh balance" reads USDC balanceOf via viem + Alchemy RPC.
3. Primary action for this idea (image cropping). App runs:
(a) Challenge — GET /api/public/x402-paid-content → expect 402 → parse
{ x402Version:2, accepts:[…] }. Pick where network==="eip155:11155420" && 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://console.optimism.io/faucet (Optimism Sepolia).
Same address goes in x402.json's payTo so the treasury receives the USDC.
- ALCHEMY_OPTIMISM_SEPOLIA_RPC_URL (+ VITE_) Free HTTPS URL from https://dashboard.alchemy.com/ (create app,
Optimism 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.