🎥 Videography & Film · previsualization

DirectorCut · x402

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 Aptos USDC fungible-asset 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.

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 videographers can act on without web3 jargon.

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

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 "DirectorCut · x402" in ONE Lovable message. Single-page x402-native paid app on Aptos 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 Aptos USDC fungible-asset 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 Aptos Testnet (Circle USDC fungible asset). 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 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 (previsualization). 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
$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.

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

See also

Adjacent entries.