Origin · x402
A high-fidelity provenance engine for luxury accessories. Pay-per-look: 0.01 USDC to instantly verify a piece's origin, ownership history, and authenticity certificates. Brands earn a micropayment every time their heritage is validated in the secondary market or during high-stakes social verification. Protocol-level verification for the next generation of digital-physical collectors.
The primitive.
The onchain primitive runs at the right moment in the flow and surfaces a clear, verifiable result that fashion designers can act on without web3 jargon.
Why this primitiveBy turning provenance into a metered service, we replace slow manual appraisals with instant, pay-per-query cryptographic certainty. x402 allows for frictionless 'status checks' where the payment acts as the trust-anchor.
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 "Origin · x402" in ONE Lovable message. Single-page x402-native paid app on Aptos Testnet.
CONCEPT
A high-fidelity provenance engine for luxury accessories. Pay-per-look: 0.01 USDC to instantly verify a piece's origin, ownership history, and authenticity certificates. Brands earn a micropayment every time their heritage is validated in the secondary market or during high-stakes social verification. Protocol-level verification for the next generation of digital-physical collectors.
Discipline: Fashion & Textile Design (jewelry provenance).
Onchain primitive: x402 micropayments on Aptos Testnet (Circle USDC fungible asset). Why: By turning provenance into a metered service, we replace slow manual appraisals with instant, pay-per-query cryptographic certainty. x402 allows for frictionless 'status checks' where the payment acts as the trust-anchor.
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 (jewelry provenance). 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.