🎮 Game Design & Interactive Media · user-generated content

Privy Story Worlds

Create and share story-driven worlds with gasless wallet logins and sponsored content publishing.

Privy social + Aptos embedded wallet· wallet UX
Section · Onchain

The primitive.

full primer →

Game designers sign in with Google through Privy — no seed phrase, no Petra install — and their user-generated content actions are signed server-side and settled on Aptos Testnet for a fraction of a cent.

Why this primitivePrivy social and sponsored tx simplify content creation without gas costs.

Kernel
Privy Google sign-in that provisions an Aptos embedded wallet, with transactions raw-signed server-side by @privy-io/node so the user never handles a key
Drives the UI as
a one-click 'Sign in with Google' that drops the user straight into the app with an Aptos address
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 "Privy Story Worlds" in ONE Lovable message. Single-page demo.

CONCEPT
Create and share story-driven worlds with gasless wallet logins and sponsored content publishing.
Discipline: Game Design & Interactive Media (user-generated content).
Onchain primitive: Privy social + Aptos embedded wallet. Why this primitive: Privy social and sponsored tx simplify content creation without gas costs.

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/privy_story_worlds (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/privy_story_worlds --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/privy_story_worlds/sources/privy_story_worlds.move):
```move
/// Privy Story Worlds — append-only public record.
/// Create and share story-driven worlds with gasless wallet logins and sponsored content publishing.
/// Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
module app::privy_story_worlds {
    use std::signer;
    use std::string::String;
    use std::vector;
    use aptos_framework::timestamp;
    use aptos_framework::event;

    #[event]
    struct Logged has drop, store { author: address, payload: String, at: u64 }

    struct Entry has store, copy, drop { author: address, payload: String, at: u64 }

    struct Ledger has key { entries: vector<Entry> }

    fun init_module(publisher: &signer) {
        move_to(publisher, Ledger { entries: vector::empty<Entry>() });
    }

    /// Append a payload (CID, hash or title) to the public ledger.
    public entry fun log(author: &signer, payload: String) acquires Ledger {
        let ledger = borrow_global_mut<Ledger>(@app);
        let entry = Entry { author: signer::address_of(author), payload, at: timestamp::now_seconds() };
        vector::push_back(&mut ledger.entries, entry);
        event::emit(Logged { author: entry.author, payload: entry.payload, at: entry.at });
    }

    #[view]
    public fun entries(): vector<Entry> acquires Ledger { borrow_global<Ledger>(@app).entries }

    #[view]
    public fun count(): u64 acquires Ledger { vector::length(&borrow_global<Ledger>(@app).entries) }
}
```

MOVE GOTCHAS (these bite every time)
- `init_module` runs once at publish; a missing `Ledger` resource means you
  published before adding it — republish, don't patch at runtime.
- Every function touching a global needs `acquires Ledger`.
- Entry-function args can only be primitives / String / vector — no structs.
- Read state with `#[view]` functions via `aptos.view({ payload: { function:
  `${moduleAddress}::privy_story_worlds::entries` } })`, never by parsing 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. Every user-generated content action the user performs is written to Aptos Testnet with `privy_story_worlds::log(payload)` — built, raw-signed with @privy-io/node and submitted server-side, so the user never handles a key. Show the Aptos Explorer tx link and the APT gas actually spent (be honest: Aptos gas is fractions of a cent, but it is not sponsored).
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
$4B
UGC game market
SAM
$850M
indie narrative platforms
SOM
$170M
gasless content publishing

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

See also

Adjacent entries.