🎨 Visual Art · chromatic art NFTs

Chromatix · x402

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.

x402 micropayments on Aptos Testnet· x402 native
Section · Onchain

The primitive.

full primer →

The onchain primitive runs at the right moment in the flow and surfaces a clear, verifiable result that painters can act on without web3 jargon.

Why this primitiveBy 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.

Kernel
an x402 v2 paywall metering access at 0.01 USDC per call — the payer submits a Circle USDC fungible-asset transfer on Aptos Testnet and a self-hosted facilitator verifies the committed transaction
Drives the UI as
a 'pay 0.01 USDC to unlock' button that returns the Aptos tx hash and links straight to Aptos Explorer
Appendix · Secrets

Required keys.

APTOS_DEPLOYER_PRIVATE_KEY
Exported from a Petra "classic" account with the network set to Testnet. Fund it at the Aptos faucet.
open ↗
APTOS_RPC_URL
Alchemy Aptos Testnet fullnode. Create a free app → copy the HTTPS URL.
open ↗
PRIVY_APP_ID
Enables Google sign-in and the Aptos embedded wallet.
open ↗
PRIVY_APP_SECRET
Server-side raw signing — Aptos cannot be signed in the browser.
open ↗
PINATA_JWT
Pins images / JSON / manifests to IPFS.
open ↗

Add these in your Lovable project under Settings → Secrets before pasting the prompt below.

Appendix · Mega-prompt

The build prompt.

Paste into a fresh Lovable project. Make sure all five secrets above are set first. read the build strategy →

Build "Chromatix · x402" in ONE Lovable message. Single-page x402-native paid app on Aptos 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 Aptos Testnet (Circle USDC fungible asset). 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 flows beyond the Privy drop-in.
- NO Move module to publish. x402 settles in the existing Circle USDC fungible asset
  0x69091fbab5f7d635ee7ac5098cf0c1efbe31d68fec0f2cd565e8d168daf52832 on Aptos Testnet (chain id 2).
- Self-host the facilitator as ONE TanStack server route inside the same app (shape below).
- At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea).
- Skip tests, skip CI, skip docs. Ship the demo.

STACK
- React + Vite + TanStack Start (the template Lovable ships).
- @aptos-labs/ts-sdk for everything on-chain. No viem, no ethers, no ABIs.
- Privy wraps <App /> for Google/email login and provisions the Aptos embedded wallet.
  Aptos is a Tier-2 chain in Privy: the browser SDK CANNOT sign it. All signing happens
  server-side with @privy-io/node raw signing (rule 1).
- Aptos client (server): new Aptos(new AptosConfig({ network: Network.TESTNET,
  fullnode: process.env.APTOS_RPC_URL })). Put the Alchemy Aptos Testnet URL
  (https://aptos-testnet.g.alchemy.com/v2/<KEY>/v1) in a secret — the public fullnode
  rate-limits balance reads under hackathon load.

TWELVE NON-OBVIOUS RULES (get any of these wrong and the demo silently fails)

1. THE APTOS x402 FLOW IS "USER SUBMITS FIRST", not EIP-3009. Aptos USDC has no
   transferWithAuthorization and no permit — there is nothing a relayer can co-sign.
   The payer submits `0x1::primary_fungible_store::transfer` themselves, pays their own
   APT gas, and hands the server the resulting TRANSACTION HASH. The facilitator verifies
   that committed transaction on-chain and unlocks. Never try to port EIP-712 here.

2. Signing bridge (server-side, the whole trick):
     const tx  = await aptos.transaction.build.simple({ sender: wallet.address, data: {
       function: "0x1::primary_fungible_store::transfer",
       typeArguments: ["0x1::fungible_asset::Metadata"],
       functionArguments: [USDC_METADATA, payTo, amount] } });
     const msg = generateSigningMessageForTransaction(tx);
     // NOTE: rawSign nests under `params`, and the public key MUST be normalised
     // to raw 32 bytes first — see SECTIONS C, D and E of the APTOS SKILL PACK below.
     const sig = await privy.wallets().rawSign(wallet.id,
       { params: { hash: Hex.fromHexInput(msg).toString() } });
     const auth = new AccountAuthenticatorEd25519(
       new Ed25519PublicKey(normalizeEd25519PublicKey(wallet.publicKey)),
       new Ed25519Signature(sig));
     const pending = await aptos.transaction.submit.simple({ transaction: tx, senderAuthenticator: auth });
     await aptos.waitForTransaction({ transactionHash: pending.hash });
   `typeArguments` MUST be ["0x1::fungible_asset::Metadata"] — omit it and the entry
   function does not resolve.

3. x402 v2 envelope shape, Aptos variant (NOT v1's { scheme, network, payload } at top level):
     { "x402Version": 2,
       "accepted": { /* echo the full PaymentRequirement you picked, verbatim */ },
       "payload":  { "transaction": "0x<committed tx hash>", "sender": "0x<payer address>" } }
   base64 it into the PAYMENT-SIGNATURE header.

4. Network id is CAIP-2: "aptos:testnet" (NOT "eip155:*", NOT "aptos-testnet"). Scheme is "exact".

5. `asset` is the USDC fungible-asset METADATA OBJECT ADDRESS
   (0x69091fbab5f7d635ee7ac5098cf0c1efbe31d68fec0f2cd565e8d168daf52832), not an ERC-20 contract.
   Testnet USDC lives at a different address than mainnet — never copy the mainnet one.

6. Amount is atomic units, string. Aptos USDC has 6 decimals — "10000" = 0.01 USDC.
   APT has 8 decimals (octas); do not mix the two when showing balances.

7. Header names are literal-cased and non-standard: PAYMENT-SIGNATURE (request) and
   PAYMENT-RESPONSE (response). Read case-insensitively, but SEND exactly that casing.

8. Aptos addresses are NOT case- or padding-stable. `0x0abc…` and `0xabc…` are the same
   account. Normalise before every comparison:
     const norm = (a) => String(a ?? "").toLowerCase().replace(/^0x0*/, "0x");

9. Read balances with fungible-asset APIs, not coin APIs:
     aptos.getCurrentFungibleAssetBalances({ options: { where: {
       owner_address: { _eq: addr }, asset_type: { _eq: USDC_METADATA } } } })
   and `aptos.getAccountAPTAmount({ accountAddress })` for gas. A user with USDC but no
   APT cannot pay — check APT first and link the faucet.

10. Server-side verification (all of these, in order, before unlocking):
    (a) getTransactionByHash → tx.type === "user_transaction" (a pending tx has no type yet).
    (b) tx.success === true. Inclusion is not success — surface tx.vm_status on failure.
    (c) norm(tx.sender) === norm(envelope.payload.sender).
    (d) tx.payload.function === "0x1::primary_fungible_store::transfer".
    (e) args[0] is the metadata object — it arrives as { inner: "0x…" } OR a bare string.
        Unwrap `.inner` before comparing, or every payment reads as wrong_asset.
    (f) norm(args[1]) === treasury address, BigInt(args[2]) >= required amount.
    (g) tx.timestamp is MICROSECONDS. Divide by 1e6 before comparing to Date.now()/1000;
        reject anything older than ~15 minutes.

11. Single-use hashes. Keep a REDEEMED Set of lowercased tx hashes and reject replays —
    otherwise one 0.01 USDC payment unlocks the endpoint forever.

12. The facilitator is ONE route: GET /api/public/x402-paid-content.
      - no PAYMENT-SIGNATURE header → 402 with { x402Version:2, accepts:[PaymentRequirement] }.
      - with header → decode → checks (10a–g) → 200 + PAYMENT-RESPONSE header (base64 JSON
        { success, transaction, network, payer }).
    The /api/public/* prefix bypasses Lovable's published-site auth — intentional for the demo.

FILE LAYOUT
  src/data/aptos.json         { network: "testnet", networkName: "Aptos Testnet", chainId: 2,
                                caip2: "aptos:testnet",
                                usdcMetadata: "0x69091fbab5f7d635ee7ac5098cf0c1efbe31d68fec0f2cd565e8d168daf52832",
                                usdcDecimals: 6, amount: "10000", amountLabel: "0.01 USDC",
                                endpoint: "/api/public/x402-paid-content",
                                usdcFaucetUrl: "https://faucet.circle.com/",
                                aptFaucetUrl: "https://aptos.dev/network/faucet",
                                explorer: "https://explorer.aptoslabs.com",
                                explorerSuffix: "?network=testnet" }
  src/lib/aptos.server.ts     Aptos client + APT/USDC balance helpers + treasury address
  src/lib/privy.server.ts     access-token verification (JWKS) + Aptos wallet lookup/create
  src/lib/aptos-tx.server.ts  build -> raw-sign -> submit bridge (rule 2)
  src/lib/x402.ts             fetchChallenge / pickRequirement / buildEnvelope / fetchPaid
  src/routes/api/public/x402-paid-content.ts   self-hosted facilitator (challenge + verify)
  src/routes/index.tsx        demo UI: sign-in -> 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 aptosCfg from "@/data/aptos.json";

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",
};
const REDEEMED = new Set<string>();
const norm = (a: unknown) => String(a ?? "").toLowerCase().replace(/^0x0*/, "0x");
// Follow rules 3-11 above inside the GET handler: no header -> 402 with accepts[];
// otherwise decode -> getTransactionByHash -> checks (10a-g) -> mark redeemed
// -> 200 with PAYMENT-RESPONSE header.
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) } },
});
```

APTOS SKILL PACK — non-obvious rules that decide whether this ships or 401s.
Everything below was learned by shipping this exact stack. Follow it literally; do not
"improve" the call shapes, they are the ones that actually work.

A. CHAIN CONSTANTS (copy verbatim — never look these up again)
   network      testnet  ->  Network.TESTNET,  chain id 2,  CAIP-2 "aptos:testnet"
   SDK          @aptos-labs/ts-sdk  (never viem / ethers / wagmi / web3.js)
   RPC          process.env.APTOS_RPC_URL — Alchemy https://aptos-testnet.g.alchemy.com/v2/<KEY>/v1
                Append "/v1" if the pasted URL is missing it. The public fullnode
                rate-limits `view` calls under load.
   USDC         fungible-asset METADATA object
                0x69091fbab5f7d635ee7ac5098cf0c1efbe31d68fec0f2cd565e8d168daf52832
                6 decimals -> "10000" atomic = 0.01 USDC. (Mainnet USDC is a DIFFERENT
                address. Testnet USDC is a fungible asset, not an ERC-20, not a coin type.)
   APT          8 decimals (octas)
   Explorer     https://explorer.aptoslabs.com + "?network=testnet" on EVERY url:
                  /txn/<hash>?network=testnet
                  /account/<addr>/modules?network=testnet
                  /fungible_asset/<metadata>/info?network=testnet
                Omit the suffix and the link silently shows mainnet / "not found".
   APT faucet   https://aptos.dev/network/faucet
   USDC faucet  https://faucet.circle.com/  (choose Aptos Testnet)
   Put all of the above in src/data/aptos.json and import it everywhere. Never retype
   an address in a component.

B. PRIVY IS A TIER-2 CHAIN ON APTOS — THE BROWSER SDK CANNOT SIGN
   There is no Aptos chain object to pass to <PrivyProvider>, no useSignTransaction,
   no useSendTransaction that speaks Aptos, no wallet hook that returns an Aptos signer.
   The ONLY job of the browser is: log the user in and call `getAccessToken()`.
   Every signature is produced server-side by @privy-io/node `wallets().rawSign()`
   inside a TanStack server function, after that function verifies the access token.
   Client:  const token = await getAccessToken();  -> pass to the server fn.
   Server:  verify token -> resolve wallet -> build tx -> rawSign -> submit.

C. USE APP-CONTROLLED WALLETS KEYED BY external_id (this is the 401 fix)
   A USER-OWNED wallet (`owner: { user_id }`) requires a `privy-authorization-signature`
   header on every sign call. The browser SDK cannot produce one for a Tier-2 chain, so
   you get: 401 Missing 'privy-authorization-signature' header or no signatures provided.
   Create the wallet with NO owner and an external_id derived from the Privy user id,
   and gate access by verifying the access token yourself:

   ```ts
   // src/lib/privy.server.ts
   import { PrivyClient, verifyAccessToken } from "@privy-io/node";
   import { createRemoteJWKSet } from "jose";
   import privyCfg from "@/data/privy.json"; // { "appId": "<public app id>" }

   // The app id is PUBLIC — ship it in src/data/privy.json and fall back to it.
   // The server env usually only carries PRIVY_APP_SECRET, and reading
   // process.env.PRIVY_APP_ID alone yields a spurious "PRIVY_APP_ID is not configured".
   const appId = () =>
     process.env["PRIVY_APP_ID"] ?? process.env["VITE_PRIVY_APP_ID"] ?? privyCfg.appId;

   export const privyClient = () =>
     new PrivyClient({ appId: appId(), appSecret: process.env["PRIVY_APP_SECRET"]! });

   let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
   export async function requirePrivyUser(accessToken: string): Promise<string> {
     if (!accessToken) throw new Error("Missing Privy access token");
     jwks ??= createRemoteJWKSet(
       new URL(`https://auth.privy.io/api/v1/apps/${appId()}/jwks.json`));
     const claims = await verifyAccessToken({
       access_token: accessToken, app_id: appId(), verification_key: jwks });
     return claims.user_id;
   }

   // external ids allow [a-zA-Z0-9_-] only, max 64 chars; `did:privy:...` must be sanitised
   const externalIdFor = (userId: string) =>
     `aptos-${userId}`.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 64);

   export async function getOrCreateAptosWallet(userId: string) {
     const client = privyClient();
     const external_id = externalIdFor(userId);
     const existing = await client.wallets()
       .list({ external_id, chain_type: "aptos", limit: 1 });
     const found = existing.data?.[0];
     if (found?.public_key) return {
       id: found.id, address: found.address,
       publicKey: normalizeEd25519PublicKey(found.public_key) };
     const created = await client.wallets().create({
       chain_type: "aptos", external_id, idempotency_key: external_id }); // NO owner
     return { id: created.id, address: created.address,
              publicKey: normalizeEd25519PublicKey(created.public_key!) };
   }

   export async function rawSignHash(walletId: string, hashHex: string) {
     const res = await privyClient().wallets()
       .rawSign(walletId, { params: { hash: hashHex as `0x${string}` } });
     return res.signature; // note the { params: { hash } } nesting — not { hash }
   }
   ```

D. NORMALISE THE PUBLIC KEY BEFORE Ed25519PublicKey
   Privy may hand back hex, base64/base64url, or a DER/SPKI-wrapped blob. Aptos demands
   raw 32 bytes and otherwise throws `PublicKey length should be 32`:

   ```ts
   export function normalizeEd25519PublicKey(raw: string): string {
     const trimmed = raw.trim();
     const hexBody = trimmed.startsWith("0x") ? trimmed.slice(2) : trimmed;
     let bytes: Uint8Array;
     if (/^[0-9a-fA-F]+$/.test(hexBody) && hexBody.length % 2 === 0 && hexBody.length >= 64) {
       bytes = new Uint8Array(hexBody.match(/../g)!.map((b) => parseInt(b, 16)));
     } else {
       const b64 = trimmed.replace(/-/g, "+").replace(/_/g, "/");
       const bin = atob(b64.padEnd(Math.ceil(b64.length / 4) * 4, "="));
       bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
     }
     if (bytes.length > 32) bytes = bytes.slice(bytes.length - 32); // strip DER/SPKI header
     if (bytes.length !== 32) throw new Error(`bad Aptos public key: ${bytes.length} bytes`);
     return `0x${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
   }
   ```

E. SIGN + SUBMIT BRIDGE (the whole trick — copy the shape exactly)
   ```ts
   // src/lib/aptos-tx.server.ts
   import { AccountAddress, AccountAuthenticatorEd25519, Ed25519PublicKey,
            Ed25519Signature, Hex, generateSigningMessageForTransaction,
            type InputGenerateTransactionPayloadData } from "@aptos-labs/ts-sdk";

   export async function signAndSubmit(wallet, data: InputGenerateTransactionPayloadData) {
     const aptos = aptosClient();
     const transaction = await aptos.transaction.build.simple({ sender: wallet.address, data });
     const signingMessage = generateSigningMessageForTransaction(transaction);
     // `hash` takes the WHOLE signing message as 0x-hex, not a 32-byte digest of it.
     const signatureHex = await rawSignHash(wallet.id, Hex.fromHexInput(signingMessage).toString());

     const publicKey = new Ed25519PublicKey(wallet.publicKey); // already normalised (D)
     // Assert before submitting: a length-correct WRONG key fails on-chain with an
     // opaque error instead of telling you the key is wrong.
     const derived  = publicKey.authKey().derivedAddress().toStringLong();
     const reported = AccountAddress.from(wallet.address).toStringLong();
     if (derived !== reported) throw new Error(`key mismatch: ${derived} vs ${reported}`);

     const senderAuthenticator = new AccountAuthenticatorEd25519(
       publicKey, new Ed25519Signature(signatureHex.startsWith("0x") ? signatureHex : `0x${signatureHex}`));
     const pending = await aptos.transaction.submit.simple({ transaction, senderAuthenticator });
     const committed = await aptos.waitForTransaction({
       transactionHash: pending.hash, options: { checkSuccess: false } });
     return { hash: pending.hash, success: committed.success, vmStatus: committed.vm_status };
   }
   ```

F. BALANCES COME FROM VIEW FUNCTIONS, NOT BALANCE FIELDS
   ```ts
   // src/lib/aptos.server.ts
   export function aptosClient() {
     const raw = process.env["APTOS_RPC_URL"];
     if (!raw) return new Aptos(new AptosConfig({ network: Network.TESTNET }));
     const base = raw.replace(/\/+$/, "");
     return new Aptos(new AptosConfig({ network: Network.TESTNET,
       fullnode: base.endsWith("/v1") ? base : `${base}/v1` }));
   }
   export const usdcBalance = async (aptos, owner) => BigInt((await aptos.view({ payload: {
     function: "0x1::primary_fungible_store::balance",
     typeArguments: ["0x1::fungible_asset::Metadata"],
     functionArguments: [owner, USDC_METADATA] } }))[0] ?? 0);
   export const aptBalance = async (aptos, owner) => {
     try { return BigInt((await aptos.view({ payload: {
       function: "0x1::coin::balance",
       typeArguments: ["0x1::aptos_coin::AptosCoin"],
       functionArguments: [owner] } }))[0] ?? 0); }
     catch { return 0n; } // an unfunded account THROWS here — never let it crash the page
   };
   ```

G. FUNDING UX IS PART OF THE DEMO (user pays their own gas)
   There is no sponsored-transaction toggle. A fresh embedded wallet has 0 APT and every
   submit fails with an unhelpful error. The wallet panel MUST show:
   the address (copyable), live APT and USDC balances, a Refresh button, the APT faucet
   link, the USDC faucet link, and an explicit warning + disabled action button while
   APT is zero.

H. SERVER-ONLY MODULE BOUNDARY
   @privy-io/node, PRIVY_APP_SECRET and any deployer key must never reach the client
   bundle. Keep them in *.server.ts, imported only from *.functions.ts handlers or from
   src/routes/api/public/* handlers (in route files use `await import()` INSIDE the
   handler). Read process.env inside .handler(), never at module scope. Browser config
   uses import.meta.env.VITE_* only.

I. FAILURE MODES — match the symptom, apply the fix, do not guess
   PublicKey length should be 32                     -> key is base64/DER; normalise (D)
   401 Missing 'privy-authorization-signature'       -> wallet is user-owned; recreate app-controlled (C)
   PRIVY_APP_ID is not configured (server)           -> fall back to the public id in src/data/privy.json (C)
   Privy import crashes SSR                          -> @privy-io/react-auth reached the server graph;
                                                        mount behind <ClientOnly> + lazy()
   key mismatch: 0x.. vs 0x..                        -> wrong wallet/key pair; re-resolve by external_id
   Move compile errors inside aptos_framework        -> Move.toml on a floating rev; pin it (below)
   view calls flake / 429                            -> set APTOS_RPC_URL to the Alchemy endpoint
   Explorer link shows mainnet / not found           -> missing ?network=testnet
   Transaction submits then instantly reverts        -> wallet has no APT; Aptos faucet

J. x402 ON APTOS IS "USER SUBMITS FIRST"
   There is no EIP-3009 / permit on Aptos, so nothing can be co-signed by a relayer.
   The payer submits a real `0x1::primary_fungible_store::transfer` (typeArguments
   ["0x1::fungible_asset::Metadata"], args [USDC_METADATA, payTo, amount]), pays their
   own APT gas, and puts the resulting TRANSACTION HASH in the v2 envelope payload:
     { x402Version: 2, accepted: <the PaymentRequirement you picked, verbatim>,
       payload: { transaction: "0x<hash>", sender: "0x<payer>" } }
   base64 -> PAYMENT-SIGNATURE header. The facilitator re-reads the committed
   transaction and verifies it. Never port the EVM signature envelope here.
   Facilitator checks, in order: requirement match -> replay set (one hash unlocks once)
   -> tx.type === "user_transaction" -> tx.success -> normalised sender match ->
   function is the FA transfer -> args[0] metadata (may arrive as { inner: "0x.." },
   unwrap it) -> recipient -> BigInt(amount) >= required -> tx.timestamp is MICROSECONDS
   (divide by 1e6) within ~15 minutes.
   Normalise every address before comparing: a => a.toLowerCase().replace(/^0x0*/, "0x").




USER FLOW (log every step in the UI)
1. Land on page -> "Sign in with Google" (Privy) -> Aptos embedded wallet auto-provisioned server-side.
2. Fund: show the wallet address + two faucet links —
   - USDC: https://faucet.circle.com/ (choose Aptos Testnet)
   - APT (gas): https://aptos.dev/network/faucet
   -> "Refresh balance" reads the USDC fungible-asset balance and the APT balance (rule 9).
3. Primary action for this idea (chromatic art NFTs). App runs:
   (a) Challenge — GET /api/public/x402-paid-content -> expect 402 -> parse
       { x402Version:2, accepts:[…] }. Pick where network==="aptos:testnet" && scheme==="exact".
   (b) Pay — server fn builds `0x1::primary_fungible_store::transfer`, Privy raw-signs it,
       submits it, and waits for commitment (rules 1, 2). The user pays their own APT gas.
   (c) Retry — GET /api/public/x402-paid-content with PAYMENT-SIGNATURE: base64({ x402Version:2,
       accepted, payload:{ transaction, sender } }).
   (d) Unlock — On 200, read PAYMENT-RESPONSE, base64-decode -> { success, transaction, network, payer }.
       Link tx to `https://explorer.aptoslabs.com/txn/<hash>?network=testnet`.
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 + Aptos embedded wallet. Docs: https://docs.privy.io/llms-full.txt
- PRIVY_APP_SECRET                    Server-side @privy-io/node raw signing. Aptos cannot be signed in the browser.
- APTOS_DEPLOYER_PRIVATE_KEY          Treasury account that receives the USDC. Export it from a Petra "classic"
                                      account (social Petra accounts expose no private key) with the network set
                                      to Testnet, and fund it: https://aptos.dev/network/faucet
- APTOS_RPC_URL                       Alchemy Aptos Testnet fullnode (https://aptos-testnet.g.alchemy.com/v2/<key>/v1).
                                      Free app at https://dashboard.alchemy.com/. Public fullnodes throttle.

APTOS DOCS: https://aptos.dev/llms-full.txt

FAILURE-MODE TABLE (fix these before shipping)
- "TypeError: Failed to fetch"                  -> You're calling a third-party facilitator. Use the same-origin /api/public/x402-paid-content.
- "invalid_payload: envelope shape"             -> Sent the EVM v1/v2 signature envelope. Aptos payload is { transaction, sender } (rule 3).
- "invalid_payload: wrong_asset"                -> args[0] arrives as { inner: "0x…" }; unwrap it before comparing (rule 10e).
- "sender_mismatch" / "wrong_recipient"         -> Comparing un-normalised addresses. Strip leading zeros and lowercase (rule 8).
- "not a committed user transaction"            -> You verified before waitForTransaction resolved. Wait for commitment first.
- "transaction_too_old"                         -> tx.timestamp is microseconds, not seconds (rule 10g).
- "Simulation failed: INSUFFICIENT_BALANCE"     -> Wallet has USDC but no APT for gas. Aptos faucet, then refresh.
- "EFUNGIBLE_STORE_NOT_FOUND" / balance 0       -> Wallet never received testnet USDC. Circle faucet, choose Aptos Testnet.
- "FUNCTION_RESOLUTION_FAILURE"                 -> Missing typeArguments ["0x1::fungible_asset::Metadata"] (rule 2).
- "transaction_already_redeemed"                -> Working as intended: one hash unlocks once (rule 11).

CREDIT (must appear in UI footer):
Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
Appendix · Market

Market sizing.

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 Base.

Indicative figures for hackathon pitches — refine with your own research before raising.

See also

Adjacent entries.