Gas-Free Level Sharing
Share custom levels with friends through gasless wallet sign-in and sponsored access transactions.
Privy social + Aptos embedded wallet· wallet UX
Section · Onchain
full primer →The primitive.
Game designers sign in with Google through Privy — no seed phrase, no Petra install — and their user content actions are signed server-side and settled on Aptos Testnet for a fraction of a cent.
Why this primitivePrivy wallet and sponsored tx enable easy, gasless content sharing.
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
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 ↗Add these in your Lovable project under Settings → Secrets before pasting the prompt below.
Appendix · Mega-prompt
The build prompt.
budget · 1 message
Paste into a fresh Lovable project. Make sure all five secrets above are set first. read the build strategy →
Build "Gas-Free Level Sharing" in ONE Lovable message. Single-page demo.
CONCEPT
Share custom levels with friends through gasless wallet sign-in and sponsored access transactions.
Discipline: Game Design & Interactive Media (user content).
Onchain primitive: Privy social + Aptos embedded wallet. Why this primitive: Privy wallet and sponsored tx enable easy, gasless content sharing.
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/gas_free_level_sharing (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/gas_free_level_sharing --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/gas_free_level_sharing/sources/gas_free_level_sharing.move):
```move
/// Gas-Free Level Sharing — append-only public record.
/// Share custom levels with friends through gasless wallet sign-in and sponsored access transactions.
/// Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
module app::gas_free_level_sharing {
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}::gas_free_level_sharing::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 content action the user performs is written to Aptos Testnet with `gas_free_level_sharing::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 14Market sizing.
TAM
$3B
user-generated level market
SAM
$600M
indie game modding
SOM
$120M
gasless content distribution
Indicative figures for hackathon pitches — refine with your own research before raising.
Adjacent entries.
multiplayer coordination
Gasless Guilds
Seamlessly create and join guilds with gas-free onboarding and instant member transactions.
reward distributionSponsored Loot Drops
Distribute in-game rewards directly to players' wallets without any gas fees.
XR social spacesPrivy VR Lobby
Enter virtual lobbies with gas-free wallet login and seamless social interactions.
digital fashionOnchain Avatar Store
Buy and customize avatars with zero gas fees using embedded wallets and sponsored transactions.