KINETIC · x402
A high-fidelity motion metadata relay for VFX houses and remote editors. Pay 0.01 USDC per frame to stream verified XYZ coordinates and lens telemetry directly into your compositor. Eliminate manual tracking labor and 'black box' data handover with a cryptographically signed audit trail of every camera move.
The primitive.
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 primitiveBy turning motion metadata into a metered stream, the camera operator is paid in real-time for the quality of their data, and post-production teams only pay for the specific frames they process. It replaces bulky file transfers with a pay-per-use data API.
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 "KINETIC · x402" in ONE Lovable message. Single-page x402-native paid app on Aptos Testnet.
CONCEPT
A high-fidelity motion metadata relay for VFX houses and remote editors. Pay 0.01 USDC per frame to stream verified XYZ coordinates and lens telemetry directly into your compositor. Eliminate manual tracking labor and 'black box' data handover with a cryptographically signed audit trail of every camera move.
Discipline: Videography & Film (motion metadata).
Onchain primitive: x402 micropayments on Aptos Testnet (Circle USDC fungible asset). Why: By turning motion metadata into a metered stream, the camera operator is paid in real-time for the quality of their data, and post-production teams only pay for the specific frames they process. It replaces bulky file transfers with a pay-per-use data API.
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 (motion metadata). 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 14Market sizing.
Indicative figures for hackathon pitches — refine with your own research before raising.