🎮 Game Design & Interactive Media · digital collectible design

Collectible Lore Cards

Mint unique lore cards as NFTs proving authentic creator provenance.

Aptos Digital Asset provenance mint· onchain authorship
Section · Onchain

The primitive.

full primer →

Game designers mint each digital collectible design as an Aptos Digital Asset pointing at an IPFS CID, so authorship and timestamp are provable from a single Aptos Explorer link.

Why this primitiveProvenance minting ensures collectible authenticity and creator ownership onchain.

Kernel
an Aptos Digital Asset (Token v2) collection whose tokens point at an IPFS CID, minted by the creator's own embedded wallet
Drives the UI as
a 'mint to claim authorship' button that returns the token object address, owner and Aptos Explorer link
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 "Collectible Lore Cards" in ONE Lovable message. Single-page demo.

CONCEPT
Mint unique lore cards as NFTs proving authentic creator provenance.
Discipline: Game Design & Interactive Media (digital collectible design).
Onchain primitive: Aptos Digital Asset provenance mint. Why this primitive: Provenance minting ensures collectible authenticity and creator ownership onchain.

5-CREDIT BUDGET (HARD LIMIT):
- ONE single-page app. No router, no Lovable Cloud, no database, no auth flows beyond Privy drop-in.
- ONE Move module, <=80 lines, published to Aptos Testnet, source visible on Aptos Explorer.
- Privy is always the auth layer (Google login, Aptos embedded wallet, server-side raw signing).
- Pinata/IPFS only if the idea genuinely needs to store a file or metadata.
- At most ONE AI call per user action (use Lovable AI Gateway with LOVABLE_API_KEY if AI is part of the idea).
- Skip tests, skip CI, skip docs pages. Ship the demo, nothing else.

STACK
- React + Vite + TanStack Start single page (the index route).
- SSR-safe Privy mount is mandatory. Never import @privy-io/react-auth at
  module scope of a route file — it crashes SSR. Use
  lazy(() => import('./privy-client-entry')) inside <ClientOnly> + <Suspense>,
  and put <PrivyProvider> only inside privy-client-entry.tsx.
- PrivyProvider config (Aptos is a Tier-2 chain in Privy — there is NO chain
  object to pass, and NO useSendTransaction hook that speaks Aptos):
    <PrivyProvider appId={import.meta.env.VITE_PRIVY_APP_ID}
      config={{ loginMethods:['google','email'],
                appearance:{ theme:'dark' } }}>
- CRITICAL: the Privy React SDK CANNOT sign Aptos transactions. Signing happens
  SERVER-SIDE with @privy-io/node raw signing. The browser only sends the Privy
  access token (getAccessToken()) to a TanStack server function.
- Server session bridge (src/lib/privy.server.ts) — READ SECTION C BELOW FIRST.
  Verify the Privy access token against the app's JWKS, then resolve an
  APP-CONTROLLED Aptos wallet by external_id. Never create it with `owner`.
- Sign + submit bridge (src/lib/aptos-tx.server.ts) — exact call shapes in SECTION E below.
- Aptos client (src/lib/aptos.server.ts):
    new Aptos(new AptosConfig({ network: Network.TESTNET, fullnode: process.env.APTOS_RPC_URL }))
  Use the Alchemy Aptos Testnet URL (https://aptos-testnet.g.alchemy.com/v2/<KEY>/v1).
  The public fullnode rate-limits under hackathon load.
- The user pays their own gas in APT. There is no sponsored-tx toggle to flip —
  give every new wallet a faucet nudge (https://aptos.dev/network/faucet) and
  show the APT balance before enabling the action button.
- Move package in /move/collectible_lore_cards (kept outside the Vite bundle). Install the Aptos
  CLI (https://aptos.dev/build/cli) — there is no Hardhat, no Foundry, no ABI json.
- Move.toml pins the framework to the commit your CLI was built against; a
  floating `rev = "mainnet"` breaks the build with spec errors:
    [addresses]
    app = "_"
    [dependencies.AptosFramework]
    git = "https://github.com/aptos-labs/aptos-core.git"
    rev = "fde503186ef74658abd2c66532a8602eca33a20d"
    subdir = "aptos-move/framework/aptos-framework"
- Publish:
    aptos init --network testnet --private-key $APTOS_DEPLOYER_PRIVATE_KEY
    aptos move publish --package-dir move/collectible_lore_cards --named-addresses app=<deployer-address>
  Move source is published WITH the module — Aptos Explorer shows it under the
  account's Modules tab immediately. There is no separate verify step and no
  API key to buy.
- Write the publisher address to `src/data/contract.json` so the UI links to
  `https://explorer.aptoslabs.com/account/<address>/modules?network=testnet`
  and every tx to `https://explorer.aptoslabs.com/txn/<hash>?network=testnet`.

MOVE MODULE (move/collectible_lore_cards/sources/collectible_lore_cards.move) — Aptos Digital Asset (Token v2), not ERC-721:
```move
/// Collectible Lore Cards — provenance mints.
/// Mint unique lore cards as NFTs proving authentic creator provenance.
/// Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
module app::collectible_lore_cards {
    use std::option;
    use std::signer;
    use std::string::{Self, String};
    use aptos_token_objects::collection;
    use aptos_token_objects::token;

    const COLLECTION: vector<u8> = b"Collectible Lore Cards";

    fun init_module(publisher: &signer) {
        collection::create_unlimited_collection(
            publisher,
            string::utf8(b"Mint unique lore cards as NFTs proving authentic creator provenance."),
            string::utf8(COLLECTION),
            option::none(),
            string::utf8(b"ipfs://"),
        );
    }

    /// Mint a creator-owned token pointing at an IPFS CID.
    public entry fun mint(creator: &signer, name: String, cid: String) {
        let uri = string::utf8(b"ipfs://");
        string::append(&mut uri, cid);
        token::create_named_token(
            creator,
            string::utf8(COLLECTION),
            string::utf8(b"Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14"),
            name,
            option::none(),
            uri,
        );
        let _ = signer::address_of(creator);
    }
}
```

DIGITAL ASSET GOTCHAS
- Aptos NFTs are objects, not token ids. The UI shows the token OBJECT ADDRESS
  and links to `https://explorer.aptoslabs.com/object/<addr>?network=testnet`.
- The collection is created by `init_module` at publish time and is owned by the
  publisher; users mint named tokens into it. Names must be unique per collection.
- Query a user's holdings with the Aptos indexer GraphQL
  (`current_token_ownerships_v2`), not by scanning events.

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. MOVE PUBLISH — CLI ONLY, NO HARDHAT / FOUNDRY / ABI JSON
   - Install the Aptos CLI (https://aptos.dev/build/cli). In a sandboxed Linux build
     environment the binary is missing libudev, libssl and libstdc++; map them in
     (e.g. via Nix) before running it, otherwise the CLI dies on start.
   - Pin AptosFramework to the commit matching your CLI version. The default
     `rev = "mainnet"` fails to compile with spec errors:
       [addresses]
       app = "_"
       [dependencies.AptosFramework]
       git = "https://github.com/aptos-labs/aptos-core.git"
       rev = "fde503186ef74658abd2c66532a8602eca33a20d"
       subdir = "aptos-move/framework/aptos-framework"
   - Publish:
       aptos init --network testnet --private-key $APTOS_DEPLOYER_PRIVATE_KEY
       aptos move publish --package-dir move/<pkg> --named-addresses app=<deployer-address>
   - Move source ships WITH the bytecode: Aptos Explorer shows it under the account's
     Modules tab immediately. There is NO verify step and no API key to buy.
   - VERIFY BY CALLING A #[view] FUNCTION (e.g. `count`) after publishing. A publish
     receipt is not proof that init_module ran.
   - Write the publisher address into src/data/contract.json and build every UI link
     from it with the ?network=testnet suffix.




USER FLOW
1. Land on page -> 'Sign in with Google' (Privy) -> Aptos embedded wallet auto-provisioned server-side.
2. After the user creates a digital collectible design artefact, pin the file to IPFS via Pinata, then call `collectible_lore_cards::mint(name, cid)` through the server-side Privy raw-signing bridge. Show the token OBJECT ADDRESS, the IPFS preview (`https://gateway.pinata.cloud/ipfs/<cid>`) and the Aptos Explorer tx link.
3. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14"

REQUIRED SECRETS (Lovable -> Project Settings -> Secrets):
- APTOS_DEPLOYER_PRIVATE_KEY  Aptos Testnet publisher key. Export it from a Petra "classic" account
                              (a Google/social Petra account gives you NO private key) with the
                              network switched to Testnet, then 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 under load.
- PRIVY_APP_ID (+ VITE_PRIVY_APP_ID)  Google sign-in + 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.
- PINATA_JWT                  IPFS uploads (only if app pins media). Docs: https://docs.pinata.cloud/llms-full.txt

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

CREDIT (must appear in UI footer AND as a doc comment on the published Move module):
Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
Appendix · Market

Market sizing.

TAM
$2B
collectible card market
SAM
$400M
indie collectible designers
SOM
$40M
blockchain card marketplaces

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

See also

Adjacent entries.