starkzap-starter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1749 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * create-starkzap-app
5
+ * Interactive CLI to scaffold a Starkzap starter project
6
+ *
7
+ * Usage:
8
+ * npx create-starkzap-app
9
+ * npx create-starkzap-app my-app
10
+ */
11
+
12
+ import { createRequire } from "module";
13
+ import { execSync } from "child_process";
14
+ import { existsSync, mkdirSync, writeFileSync, cpSync } from "fs";
15
+ import { join, resolve } from "path";
16
+ import { createInterface } from "readline";
17
+ import { fileURLToPath } from "url";
18
+
19
+ const __dirname = fileURLToPath(new URL(".", import.meta.url));
20
+
21
+ // ─── Colours ──────────────────────────────────────────────────────────────────
22
+ const c = {
23
+ reset: "\x1b[0m",
24
+ bold: "\x1b[1m",
25
+ dim: "\x1b[2m",
26
+ cyan: "\x1b[36m",
27
+ green: "\x1b[32m",
28
+ yellow: "\x1b[33m",
29
+ blue: "\x1b[34m",
30
+ magenta:"\x1b[35m",
31
+ red: "\x1b[31m",
32
+ white: "\x1b[37m",
33
+ gray: "\x1b[90m",
34
+ };
35
+
36
+ const bold = (s) => `${c.bold}${s}${c.reset}`;
37
+ const cyan = (s) => `${c.cyan}${s}${c.reset}`;
38
+ const green = (s) => `${c.green}${s}${c.reset}`;
39
+ const yellow = (s) => `${c.yellow}${s}${c.reset}`;
40
+ const blue = (s) => `${c.blue}${s}${c.reset}`;
41
+ const gray = (s) => `${c.gray}${s}${c.reset}`;
42
+ const red = (s) => `${c.red}${s}${c.reset}`;
43
+ const dim = (s) => `${c.dim}${s}${c.reset}`;
44
+
45
+ // ─── Prompts ──────────────────────────────────────────────────────────────────
46
+ function createReadline() {
47
+ return createInterface({ input: process.stdin, output: process.stdout });
48
+ }
49
+
50
+ async function prompt(question) {
51
+ const rl = createReadline();
52
+ return new Promise((resolve) => {
53
+ rl.question(question, (answer) => {
54
+ rl.close();
55
+ resolve(answer.trim());
56
+ });
57
+ });
58
+ }
59
+
60
+ async function select(question, options) {
61
+ console.log(`\n${bold(question)}`);
62
+ options.forEach((opt, i) => {
63
+ console.log(` ${cyan(`[${i + 1}]`)} ${opt.label} ${gray(opt.hint ?? "")}`);
64
+ });
65
+
66
+ while (true) {
67
+ const answer = await prompt(`\n${gray("›")} `);
68
+ const num = parseInt(answer, 10);
69
+ if (num >= 1 && num <= options.length) {
70
+ return options[num - 1];
71
+ }
72
+ console.log(red(` Please enter a number between 1 and ${options.length}`));
73
+ }
74
+ }
75
+
76
+ async function confirm(question, defaultYes = true) {
77
+ const hint = defaultYes ? gray("[Y/n]") : gray("[y/N]");
78
+ const answer = await prompt(`${bold(question)} ${hint} `);
79
+ if (answer === "") return defaultYes;
80
+ return answer.toLowerCase().startsWith("y");
81
+ }
82
+
83
+ // ─── Banner ───────────────────────────────────────────────────────────────────
84
+ function printBanner() {
85
+ console.clear();
86
+ console.log(`
87
+ ${c.bold}${c.blue} ███████╗████████╗ █████╗ ██████╗ ██╗ ██╗███████╗ █████╗ ██████╗ ${c.reset}
88
+ ${c.bold}${c.blue} ██╔════╝╚══██╔══╝██╔══██╗██╔══██╗██║ ██╔╝╚══███╔╝██╔══██╗██╔══██╗${c.reset}
89
+ ${c.bold}${c.cyan} ███████╗ ██║ ███████║██████╔╝█████╔╝ ███╔╝ ███████║██████╔╝${c.reset}
90
+ ${c.bold}${c.cyan} ╚════██║ ██║ ██╔══██║██╔══██╗██╔═██╗ ███╔╝ ██╔══██║██╔═══╝ ${c.reset}
91
+ ${c.bold}${c.white} ███████║ ██║ ██║ ██║██║ ██║██║ ██╗███████╗██║ ██║██║ ${c.reset}
92
+ ${c.bold}${c.white} ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ${c.reset}
93
+
94
+ ${bold("create-starkzap-app")} ${dim("— Scaffold your Starknet app in seconds")}
95
+ ${gray("Built on Starknet · Powered by Starkzap SDK")}
96
+ `);
97
+ }
98
+
99
+ // ─── Summary box ──────────────────────────────────────────────────────────────
100
+ function printSummary(config) {
101
+ const walletLabel = config.framework.id === "expo"
102
+ ? "Privy (Expo — @privy-io/expo)"
103
+ : config.wallet === "privy"
104
+ ? "Privy (social/email login)"
105
+ : "Cartridge Controller (passkey/social)";
106
+
107
+ console.log(`
108
+ ${c.bold}${c.blue} ┌─────────────────────────────────────────┐${c.reset}
109
+ ${c.bold}${c.blue} │ Your project config │${c.reset}
110
+ ${c.bold}${c.blue} ├─────────────────────────────────────────┤${c.reset}
111
+ ${c.bold}${c.blue} │${c.reset} ${dim("Project")} ${bold(config.projectName.padEnd(27))}${c.bold}${c.blue}│${c.reset}
112
+ ${c.bold}${c.blue} │${c.reset} ${dim("Framework")} ${cyan(config.framework.label.padEnd(27))}${c.bold}${c.blue}│${c.reset}
113
+ ${c.bold}${c.blue} │${c.reset} ${dim("Wallet")} ${green(walletLabel.padEnd(27))}${c.bold}${c.blue}│${c.reset}
114
+ ${c.bold}${c.blue} │${c.reset} ${dim("Network")} ${yellow(config.network.padEnd(27))}${c.bold}${c.blue}│${c.reset}
115
+ ${c.bold}${c.blue} │${c.reset} ${dim("TypeScript")} ${config.typescript ? green("Yes") : gray("No")} ${" ".repeat(24)}${c.bold}${c.blue}│${c.reset}
116
+ ${c.bold}${c.blue} └─────────────────────────────────────────┘${c.reset}`);
117
+ }
118
+
119
+ // ─── File generators ──────────────────────────────────────────────────────────
120
+
121
+ function genPackageJson(config) {
122
+ const isNextjs = config.framework.id.startsWith("nextjs");
123
+ const isVite = config.framework.id === "vite-react";
124
+
125
+ const deps = {
126
+ starkzap: "latest",
127
+ starknet: "^6.11.0",
128
+ clsx: "^2.1.1",
129
+ "tailwind-merge": "^2.3.0",
130
+ };
131
+
132
+ if (isNextjs) {
133
+ deps.next = "^14.2.0";
134
+ deps.react = "^18.3.0";
135
+ deps["react-dom"] = "^18.3.0";
136
+ } else {
137
+ deps.react = "^18.3.0";
138
+ deps["react-dom"] = "^18.3.0";
139
+ }
140
+
141
+ if (config.wallet === "privy") {
142
+ deps["@privy-io/react-auth"] = "^1.80.0";
143
+ deps["@privy-io/node"] = "^1.10.0";
144
+ } else {
145
+ deps["@cartridge/controller"] = "latest";
146
+ }
147
+
148
+ const devDeps = {
149
+ typescript: "^5",
150
+ "@types/react": "^18",
151
+ "@types/react-dom": "^18",
152
+ tailwindcss: "^3.4.0",
153
+ autoprefixer: "^10.4.0",
154
+ postcss: "^8.4.0",
155
+ };
156
+
157
+ if (isNextjs) {
158
+ devDeps["@types/node"] = "^20";
159
+ devDeps.eslint = "^8";
160
+ devDeps["eslint-config-next"] = "14.2.0";
161
+ } else {
162
+ devDeps["@vitejs/plugin-react"] = "^4.3.0";
163
+ devDeps.vite = "^5.4.0";
164
+ devDeps["vite-tsconfig-paths"] = "^5.0.0";
165
+ }
166
+
167
+ const scripts = isNextjs
168
+ ? { dev: "next dev", build: "next build", start: "next start", lint: "next lint", "type-check": "tsc --noEmit" }
169
+ : { dev: "vite", build: "tsc && vite build", preview: "vite preview", "type-check": "tsc --noEmit" };
170
+
171
+ return JSON.stringify({ name: config.projectName, version: "0.1.0", private: true, scripts, dependencies: deps, devDependencies: devDeps }, null, 2);
172
+ }
173
+
174
+ function genEnvExample(config) {
175
+ const privyLines = config.wallet === "privy"
176
+ ? `
177
+ # ── Privy ─────────────────────────────────────────────────────
178
+ # Get from https://privy.io → App Settings
179
+ NEXT_PUBLIC_PRIVY_APP_ID=
180
+ PRIVY_APP_SECRET= # server-side only, never expose to browser`
181
+ : `
182
+ # ── Cartridge ─────────────────────────────────────────────────
183
+ # No extra keys needed — Cartridge handles auth natively`;
184
+
185
+ return `# ─────────────────────────────────────────────────────────────────
186
+ # Starkzap Starter — Environment Variables
187
+ # Copy to .env.local and fill in your values.
188
+ # NEVER commit .env.local to source control.
189
+ # ─────────────────────────────────────────────────────────────────
190
+
191
+ # Network: "sepolia" (testnet) or "mainnet"
192
+ NEXT_PUBLIC_STARKNET_NETWORK=${config.network}
193
+
194
+ # Your Starknet RPC endpoint
195
+ # Free options: https://starknet-sepolia.public.blastapi.io
196
+ # https://free-rpc.nethermind.io/sepolia-juno
197
+ NEXT_PUBLIC_STARKNET_RPC_URL=https://starknet-sepolia.public.blastapi.io
198
+ ${privyLines}
199
+
200
+ # ── AVNU Paymaster (gasless) ───────────────────────────────────
201
+ # Optional — leave blank to skip gas sponsorship
202
+ NEXT_PUBLIC_AVNU_API_KEY=
203
+
204
+ # ── App ───────────────────────────────────────────────────────
205
+ NEXT_PUBLIC_APP_NAME=My Starkzap App
206
+ NEXT_PUBLIC_APP_URL=http://localhost:3000
207
+ `;
208
+ }
209
+
210
+ function genStarkzapLib(config) {
211
+ const walletImport = config.wallet === "privy"
212
+ ? `import { StarkZap } from "starkzap";`
213
+ : `import { StarkZap } from "starkzap";\nimport "@cartridge/controller";`;
214
+
215
+ return `/**
216
+ * lib/starkzap.ts — Single SDK instance, import anywhere.
217
+ */
218
+ ${walletImport}
219
+
220
+ export type Network = "mainnet" | "sepolia";
221
+
222
+ export const network: Network =
223
+ (process.env.NEXT_PUBLIC_STARKNET_NETWORK as Network) ?? "sepolia";
224
+
225
+ const rpcUrl = process.env.NEXT_PUBLIC_STARKNET_RPC_URL;
226
+
227
+ export const sdk = new StarkZap({
228
+ network,
229
+ ...(rpcUrl ? { rpcUrl } : {}),
230
+ });
231
+ `;
232
+ }
233
+
234
+ function genUseWalletPrivy() {
235
+ return `"use client";
236
+ /**
237
+ * hooks/useWallet.ts — Privy social/email wallet connection.
238
+ *
239
+ * Users sign in with email, Google, Apple — no seed phrases needed.
240
+ * Keys are managed server-side by Privy.
241
+ *
242
+ * Docs: https://docs.starknet.io/build/starkzap/wallets/privy
243
+ */
244
+
245
+ import { useState, useCallback } from "react";
246
+ import { PrivySigner, OnboardStrategy, accountPresets } from "starkzap";
247
+ import { sdk } from "@/lib/starkzap";
248
+
249
+ export type WalletState = {
250
+ wallet: Awaited<ReturnType<typeof sdk.connectWallet>> | null;
251
+ address: string | null;
252
+ isConnecting: boolean;
253
+ isReady: boolean;
254
+ error: string | null;
255
+ connect: () => Promise<void>;
256
+ disconnect: () => void;
257
+ };
258
+
259
+ export function useWallet(): WalletState {
260
+ const [wallet, setWallet] = useState<WalletState["wallet"]>(null);
261
+ const [address, setAddress] = useState<string | null>(null);
262
+ const [isConnecting, setIsConnecting] = useState(false);
263
+ const [isReady, setIsReady] = useState(false);
264
+ const [error, setError] = useState<string | null>(null);
265
+
266
+ const connect = useCallback(async () => {
267
+ setIsConnecting(true);
268
+ setError(null);
269
+
270
+ try {
271
+ // 1. Get the Privy access token (from your Privy React provider)
272
+ // Replace this with: const { getAccessToken } = usePrivy();
273
+ const accessToken = await getPrivyAccessToken(); // implement this
274
+
275
+ // 2. Ask your backend to create or retrieve the user's Starknet wallet
276
+ const walletRes = await fetch("/api/wallet/starknet", {
277
+ method: "POST",
278
+ headers: {
279
+ "Content-Type": "application/json",
280
+ Authorization: \`Bearer \${accessToken}\`,
281
+ },
282
+ });
283
+ const { wallet: privyWallet } = await walletRes.json();
284
+
285
+ // 3. Connect via SDK onboard (recommended) or PrivySigner directly
286
+ const onboard = await sdk.onboard({
287
+ strategy: OnboardStrategy.Privy,
288
+ accountPreset: accountPresets.argentXV050,
289
+ privy: {
290
+ resolve: async () => ({
291
+ walletId: privyWallet.id,
292
+ publicKey: privyWallet.publicKey,
293
+ serverUrl: "/api/wallet/sign", // your signing endpoint
294
+ }),
295
+ },
296
+ deploy: "if_needed",
297
+ });
298
+
299
+ const connectedWallet = onboard.wallet;
300
+ setWallet(connectedWallet);
301
+ setAddress(privyWallet.address);
302
+ setIsReady(true);
303
+ } catch (err) {
304
+ setError(err instanceof Error ? err.message : "Connection failed");
305
+ } finally {
306
+ setIsConnecting(false);
307
+ }
308
+ }, []);
309
+
310
+ const disconnect = useCallback(() => {
311
+ setWallet(null);
312
+ setAddress(null);
313
+ setIsReady(false);
314
+ setError(null);
315
+ }, []);
316
+
317
+ return { wallet, address, isConnecting, isReady, error, connect, disconnect };
318
+ }
319
+
320
+ // ── Placeholder — replace with your Privy hook ───────────────
321
+ async function getPrivyAccessToken(): Promise<string> {
322
+ // In a real app: const { getAccessToken } = usePrivy(); return getAccessToken();
323
+ throw new Error("Implement getPrivyAccessToken using usePrivy() hook");
324
+ }
325
+ `;
326
+ }
327
+
328
+ function genUseWalletCartridge() {
329
+ return `"use client";
330
+ /**
331
+ * hooks/useWallet.ts — Cartridge Controller wallet connection.
332
+ *
333
+ * Users sign in with Google, Twitter, or passkey (Face ID / Touch ID).
334
+ * Policy-matching transactions are automatically gasless (sponsored by Cartridge).
335
+ *
336
+ * Docs: https://docs.starknet.io/build/starkzap/wallets/cartridge
337
+ */
338
+
339
+ import { useState, useCallback } from "react";
340
+ import { OnboardStrategy } from "starkzap";
341
+ import { sdk } from "@/lib/starkzap";
342
+
343
+ export type WalletState = {
344
+ wallet: Awaited<ReturnType<typeof sdk.connectWallet>> | null;
345
+ address: string | null;
346
+ isConnecting: boolean;
347
+ isReady: boolean;
348
+ error: string | null;
349
+ connect: () => Promise<void>;
350
+ disconnect: () => void;
351
+ };
352
+
353
+ /**
354
+ * Define which contracts/methods Cartridge will sponsor.
355
+ * Only transactions matching these policies are gasless.
356
+ * Edit this list to match your app's contracts.
357
+ */
358
+ const CARTRIDGE_POLICIES = [
359
+ {
360
+ // STRK transfers — gasless
361
+ target: "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d",
362
+ method: "transfer",
363
+ },
364
+ // Add your contract addresses here:
365
+ // { target: "0xYOUR_CONTRACT", method: "your_method" },
366
+ ];
367
+
368
+ export function useWallet(): WalletState {
369
+ const [wallet, setWallet] = useState<WalletState["wallet"]>(null);
370
+ const [address, setAddress] = useState<string | null>(null);
371
+ const [isConnecting, setIsConnecting] = useState(false);
372
+ const [isReady, setIsReady] = useState(false);
373
+ const [error, setError] = useState<string | null>(null);
374
+
375
+ const connect = useCallback(async () => {
376
+ setIsConnecting(true);
377
+ setError(null);
378
+
379
+ try {
380
+ // Cartridge opens a popup for social/passkey login.
381
+ // Users approve policies once — matching txs are gasless after that.
382
+ const onboard = await sdk.onboard({
383
+ strategy: OnboardStrategy.Cartridge,
384
+ cartridge: {
385
+ policies: CARTRIDGE_POLICIES,
386
+ },
387
+ deploy: "if_needed",
388
+ });
389
+
390
+ const connectedWallet = onboard.wallet;
391
+ const userAddress = await connectedWallet.getAddress();
392
+
393
+ setWallet(connectedWallet);
394
+ setAddress(userAddress);
395
+ setIsReady(true);
396
+ } catch (err) {
397
+ const msg = err instanceof Error ? err.message : "Connection failed";
398
+ // Guide users if popup was blocked
399
+ if (msg.toLowerCase().includes("popup")) {
400
+ setError("Popup was blocked. Please allow popups for this site and try again.");
401
+ } else {
402
+ setError(msg);
403
+ }
404
+ } finally {
405
+ setIsConnecting(false);
406
+ }
407
+ }, []);
408
+
409
+ const disconnect = useCallback(() => {
410
+ setWallet(null);
411
+ setAddress(null);
412
+ setIsReady(false);
413
+ setError(null);
414
+ }, []);
415
+
416
+ return { wallet, address, isConnecting, isReady, error, connect, disconnect };
417
+ }
418
+ `;
419
+ }
420
+
421
+ function genPrivyApiRoute() {
422
+ return `/**
423
+ * app/api/wallet/starknet/route.ts
424
+ *
425
+ * Creates or retrieves a Privy-managed Starknet wallet for a user.
426
+ * Called client-side after Privy authentication.
427
+ *
428
+ * POST /api/wallet/starknet
429
+ * Body: { userId?: string }
430
+ */
431
+
432
+ import { NextResponse } from "next/server";
433
+ import { PrivyClient } from "@privy-io/node";
434
+
435
+ const privy = new PrivyClient({
436
+ appId: process.env.PRIVY_APP_ID!,
437
+ appSecret: process.env.PRIVY_APP_SECRET!,
438
+ });
439
+
440
+ export async function POST(req: Request) {
441
+ try {
442
+ const { userId } = await req.json();
443
+
444
+ // Server-managed wallet (no user_id) — backend signs, no JWT needed.
445
+ // To link to a Privy user, pass user_id here.
446
+ const wallet = await privy.wallets().create({
447
+ chain_type: "starknet",
448
+ ...(userId ? { user_id: userId } : {}),
449
+ });
450
+
451
+ return NextResponse.json({
452
+ wallet: {
453
+ id: wallet.id,
454
+ address: wallet.address,
455
+ publicKey: wallet.public_key,
456
+ },
457
+ });
458
+ } catch (err) {
459
+ const message = err instanceof Error ? err.message : "Failed to create wallet";
460
+ return NextResponse.json({ error: message }, { status: 500 });
461
+ }
462
+ }
463
+ `;
464
+ }
465
+
466
+ function genPrivySignRoute() {
467
+ return `/**
468
+ * app/api/wallet/sign/route.ts
469
+ *
470
+ * Signs a transaction hash using the Privy server-side signer.
471
+ * Private keys NEVER leave Privy's infrastructure.
472
+ *
473
+ * POST /api/wallet/sign
474
+ * Body: { walletId: string, hash: string }
475
+ */
476
+
477
+ import { NextResponse } from "next/server";
478
+ import { PrivyClient } from "@privy-io/node";
479
+
480
+ const privy = new PrivyClient({
481
+ appId: process.env.PRIVY_APP_ID!,
482
+ appSecret: process.env.PRIVY_APP_SECRET!,
483
+ });
484
+
485
+ export async function POST(req: Request) {
486
+ try {
487
+ const { walletId, hash } = await req.json();
488
+
489
+ if (!walletId || !hash) {
490
+ return NextResponse.json(
491
+ { error: "walletId and hash are required" },
492
+ { status: 400 }
493
+ );
494
+ }
495
+
496
+ const result = await privy.wallets().rawSign(walletId, {
497
+ params: { hash },
498
+ });
499
+
500
+ return NextResponse.json({ signature: result.signature });
501
+ } catch (err) {
502
+ const message = err instanceof Error ? err.message : "Signing failed";
503
+ return NextResponse.json({ error: message }, { status: 500 });
504
+ }
505
+ }
506
+ `;
507
+ }
508
+
509
+ function genNextConfig(config) {
510
+ const transpile = config.wallet === "cartridge"
511
+ ? `["starkzap", "@cartridge/controller"]`
512
+ : `["starkzap"]`;
513
+
514
+ return `/** @type {import('next').NextConfig} */
515
+ const nextConfig = {
516
+ reactStrictMode: true,
517
+ transpilePackages: ${transpile},
518
+ };
519
+
520
+ export default nextConfig;
521
+ `;
522
+ }
523
+
524
+ function genViteConfig() {
525
+ return `import { defineConfig } from "vite";
526
+ import react from "@vitejs/plugin-react";
527
+ import tsconfigPaths from "vite-tsconfig-paths";
528
+
529
+ export default defineConfig({
530
+ plugins: [react(), tsconfigPaths()],
531
+ define: {
532
+ // Required for Starknet.js in Vite
533
+ global: "globalThis",
534
+ },
535
+ resolve: {
536
+ alias: {
537
+ "@": "/src",
538
+ },
539
+ },
540
+ });
541
+ `;
542
+ }
543
+
544
+ function genReadme(config) {
545
+ const walletSection = config.wallet === "privy"
546
+ ? `### Privy (Social Login)
547
+
548
+ Users sign in with **email, Google, or Apple** — no seed phrase or wallet extension needed. Keys are managed server-side by Privy; private keys never reach your frontend.
549
+
550
+ **Required env vars:**
551
+ \`\`\`env
552
+ NEXT_PUBLIC_PRIVY_APP_ID=your-privy-app-id
553
+ PRIVY_APP_SECRET=your-privy-app-secret # server-side only
554
+ \`\`\`
555
+
556
+ Get these from [privy.io](https://privy.io) → Create an app → Settings.
557
+
558
+ **How it works:**
559
+ 1. User clicks Connect → your Privy auth flow runs
560
+ 2. Frontend gets access token → calls \`/api/wallet/starknet\` (already scaffolded)
561
+ 3. Backend creates wallet via Privy → returns walletId + publicKey
562
+ 4. Starkzap's \`OnboardStrategy.Privy\` wires the signer together
563
+ 5. Transactions are signed server-side via \`/api/wallet/sign\`
564
+
565
+ See \`src/hooks/useWallet.ts\` and \`src/app/api/wallet/\` for the full implementation.`
566
+ : `### Cartridge Controller (Passkey / Social Login)
567
+
568
+ Users sign in with **Google, Twitter, or biometrics** (Face ID, Touch ID, Windows Hello). A Cartridge popup handles auth — no extension needed.
569
+
570
+ **No extra env vars needed** — Cartridge handles auth natively.
571
+
572
+ **How policies work:**
573
+ Edit the \`CARTRIDGE_POLICIES\` array in \`src/hooks/useWallet.ts\` to define which contracts/methods Cartridge will sponsor. Users approve these once on connect — all matching transactions are then automatically gasless.
574
+
575
+ \`\`\`ts
576
+ const CARTRIDGE_POLICIES = [
577
+ { target: "0xYOUR_CONTRACT", method: "your_method" },
578
+ ];
579
+ \`\`\`
580
+
581
+ **Note:** If a popup is blocked, guide users to allow popups for your domain.`;
582
+
583
+ const framework = config.framework.id;
584
+
585
+ return `# ⚡ ${config.projectName}
586
+
587
+ Scaffolded with **create-starkzap-app** — a ${config.framework.label} starter for building on Starknet with the [Starkzap SDK](https://docs.starknet.io/build/starkzap/overview).
588
+
589
+ ## Stack
590
+
591
+ | | |
592
+ |---|---|
593
+ | **Framework** | ${config.framework.label} |
594
+ | **Wallet** | ${config.wallet === "privy" ? "Privy (social/email login)" : "Cartridge Controller (passkey/social)"} |
595
+ | **Network** | ${config.network} |
596
+ | **SDK** | Starkzap v2 |
597
+ | **Styling** | Tailwind CSS |
598
+ | **Language** | TypeScript |
599
+
600
+ ## Getting Started
601
+
602
+ ### 1. Install dependencies
603
+
604
+ \`\`\`bash
605
+ npm install
606
+ \`\`\`
607
+
608
+ ### 2. Set up environment variables
609
+
610
+ \`\`\`bash
611
+ cp .env.example .env.local
612
+ \`\`\`
613
+
614
+ Fill in your values — see \`.env.example\` for descriptions.
615
+
616
+ ### 3. Run locally
617
+
618
+ \`\`\`bash
619
+ npm run dev
620
+ \`\`\`
621
+
622
+ Open [http://localhost:3000](http://localhost:3000).
623
+
624
+ ---
625
+
626
+ ## Wallet Setup
627
+
628
+ ${walletSection}
629
+
630
+ ---
631
+
632
+ ## Project Structure
633
+
634
+ \`\`\`
635
+ src/
636
+ ├── app/
637
+ │ ├── layout.tsx # Root layout
638
+ │ ├── page.tsx # Demo page
639
+ │ ├── globals.css # Global styles
640
+ │ └── api/wallet/ # ${config.wallet === "privy" ? "Privy signing endpoints" : "(not used with Cartridge)"}
641
+ ├── components/
642
+ │ ├── wallet/
643
+ │ │ ├── WalletButton.tsx # Connect/disconnect button
644
+ │ │ └── TokenBalanceCard.tsx
645
+ │ └── payment/
646
+ │ └── PaymentForm.tsx # Gasless send form
647
+ ├── hooks/
648
+ │ ├── useWallet.ts # ${config.wallet === "privy" ? "Privy" : "Cartridge"} connection
649
+ │ ├── useTokenBalance.ts # Live ERC-20 balances
650
+ │ └── useGaslessTransfer.ts # Gasless transfer hook
651
+ └── lib/
652
+ ├── starkzap.ts # SDK instance
653
+ └── utils.ts # Helpers
654
+ \`\`\`
655
+
656
+ ---
657
+
658
+ ## Adding Features
659
+
660
+ ### Token Swap
661
+
662
+ \`\`\`ts
663
+ import { AvnuSwapProvider, getPresets, Amount } from "starkzap";
664
+ const { STRK, USDC } = getPresets(wallet.getChainId());
665
+ const tx = await wallet.swap({ tokenIn: STRK, tokenOut: USDC, amountIn: Amount.parse("10", STRK) });
666
+ await tx.wait();
667
+ \`\`\`
668
+
669
+ ### STRK Staking
670
+
671
+ \`\`\`ts
672
+ const pools = await wallet.staking().getPools();
673
+ const tx = await wallet.staking().stake({ pool: pools[0], amount: Amount.parse("100", STRK) });
674
+ await tx.wait();
675
+ \`\`\`
676
+
677
+ ---
678
+
679
+ ## Deployment
680
+
681
+ \`\`\`bash
682
+ # Vercel (recommended)
683
+ npx vercel
684
+
685
+ # Manual
686
+ npm run build && npm start
687
+ \`\`\`
688
+
689
+ Set \`NEXT_PUBLIC_STARKNET_NETWORK=mainnet\` in your production env.
690
+
691
+ ---
692
+
693
+ ## Resources
694
+
695
+ - [Starkzap Docs](https://docs.starknet.io/build/starkzap/overview)
696
+ - [Starkzap GitHub](https://github.com/keep-starknet-strange/starkzap)
697
+ - ${config.wallet === "privy" ? "[Privy Docs](https://docs.privy.io)" : "[Cartridge Docs](https://docs.cartridge.gg/controller/overview)"}
698
+ - [Starkscan Explorer](https://${config.network === "mainnet" ? "" : "sepolia."}starkscan.co)
699
+
700
+ ---
701
+
702
+ Built with [Starkzap SDK](https://docs.starknet.io/build/starkzap/overview) · Deployed on **Starknet ${config.network}**
703
+ `;
704
+ }
705
+
706
+ // ─── Expo generators ─────────────────────────────────────────────────────────
707
+
708
+ function genExpoPackageJson(config) {
709
+ return JSON.stringify({
710
+ name: config.projectName,
711
+ version: "0.1.0",
712
+ main: "expo-router/entry",
713
+ scripts: {
714
+ start: "expo start",
715
+ android: "expo start --android",
716
+ ios: "expo start --ios",
717
+ "type-check": "tsc --noEmit",
718
+ },
719
+ dependencies: {
720
+ "starkzap-native": "latest",
721
+ starkzap: "latest",
722
+ starknet: "^6.11.0",
723
+ expo: "~51.0.0",
724
+ "expo-router": "~3.5.0",
725
+ "expo-status-bar": "~1.12.1",
726
+ "react-native": "0.74.5",
727
+ react: "18.2.0",
728
+ "@privy-io/expo": "^0.5.0",
729
+ "react-native-get-random-values": "~1.11.0",
730
+ "fast-text-encoding": "^1.0.6",
731
+ buffer: "^6.0.3",
732
+ "@ethersproject/shims": "^5.7.0",
733
+ clsx: "^2.1.1",
734
+ },
735
+ devDependencies: {
736
+ typescript: "^5",
737
+ "@types/react": "~18.2.79",
738
+ "@types/react-native": "^0.73.0",
739
+ "@babel/core": "^7.24.0",
740
+ },
741
+ }, null, 2);
742
+ }
743
+
744
+ function genExpoMetroConfig() {
745
+ return `// metro.config.js
746
+ const { getDefaultConfig } = require("expo/metro-config");
747
+ const { withStarkzap } = require("starkzap-native/metro");
748
+
749
+ const config = getDefaultConfig(__dirname);
750
+
751
+ // withStarkzap injects required polyfills and resolver handling
752
+ // for starkzap-native dependencies automatically.
753
+ module.exports = withStarkzap(config);
754
+ `;
755
+ }
756
+
757
+ function genExpoAppJson(config) {
758
+ return JSON.stringify({
759
+ expo: {
760
+ name: config.projectName,
761
+ slug: config.projectName,
762
+ version: "1.0.0",
763
+ orientation: "portrait",
764
+ scheme: config.projectName.toLowerCase().replace(/\s+/g, "-"),
765
+ userInterfaceStyle: "dark",
766
+ ios: { supportsTablet: true, bundleIdentifier: `com.starkzap.${config.projectName}` },
767
+ android: { adaptiveIcon: { backgroundColor: "#04060f" }, package: `com.starkzap.${config.projectName}` },
768
+ plugins: ["expo-router"],
769
+ experiments: { typedRoutes: true },
770
+ },
771
+ }, null, 2);
772
+ }
773
+
774
+ function genExpoBabelConfig() {
775
+ return `module.exports = function (api) {
776
+ api.cache(true);
777
+ return {
778
+ presets: ["babel-preset-expo"],
779
+ };
780
+ };
781
+ `;
782
+ }
783
+
784
+ function genExpoTsConfig() {
785
+ return JSON.stringify({
786
+ extends: "expo/tsconfig.base",
787
+ compilerOptions: {
788
+ strict: true,
789
+ paths: { "@/*": ["./*"] },
790
+ },
791
+ include: ["**/*.ts", "**/*.tsx", ".expo/types/**/*.d.ts", "expo-env.d.ts"],
792
+ }, null, 2);
793
+ }
794
+
795
+ function genExpoEnvExample(config) {
796
+ return `# ─────────────────────────────────────────────────────────────────
797
+ # Starkzap Expo Starter — Environment Variables
798
+ # Copy to .env and fill in your values.
799
+ # NEVER commit .env to source control.
800
+ # ─────────────────────────────────────────────────────────────────
801
+
802
+ # Network: "sepolia" (testnet) or "mainnet"
803
+ EXPO_PUBLIC_STARKNET_NETWORK=${config.network}
804
+
805
+ # Free RPC endpoints:
806
+ # Blast: https://starknet-sepolia.public.blastapi.io
807
+ EXPO_PUBLIC_STARKNET_RPC_URL=https://starknet-sepolia.public.blastapi.io
808
+
809
+ # ── Privy ─────────────────────────────────────────────────────────
810
+ # Get from https://privy.io → Create app → Expo/React Native
811
+ # Important: create an Expo app in Privy, NOT a Next.js app
812
+ EXPO_PUBLIC_PRIVY_APP_ID=
813
+
814
+ # ── AVNU Paymaster (gasless) ──────────────────────────────────────
815
+ # Optional — leave blank to skip gas sponsorship
816
+ EXPO_PUBLIC_AVNU_API_KEY=
817
+ `;
818
+ }
819
+
820
+ function genExpoStarkzapLib(config) {
821
+ return `/**
822
+ * lib/starkzap.ts — Single Starkzap SDK instance for Expo.
823
+ *
824
+ * Import from "starkzap-native" (not "starkzap") in React Native projects.
825
+ * The API is identical — starkzap-native adds Metro polyfills on top.
826
+ */
827
+ import { StarkZap } from "starkzap-native";
828
+
829
+ export type Network = "mainnet" | "sepolia";
830
+
831
+ export const network: Network =
832
+ (process.env.EXPO_PUBLIC_STARKNET_NETWORK as Network) ?? "sepolia";
833
+
834
+ const rpcUrl = process.env.EXPO_PUBLIC_STARKNET_RPC_URL;
835
+
836
+ export const sdk = new StarkZap({
837
+ network,
838
+ ...(rpcUrl ? { rpcUrl } : {}),
839
+ });
840
+ `;
841
+ }
842
+
843
+ function genExpoUseWallet() {
844
+ return `/**
845
+ * hooks/useWallet.ts — Privy wallet connection for Expo (React Native).
846
+ *
847
+ * Uses @privy-io/expo — the mobile Privy SDK.
848
+ * Users sign in with email, Google, or Apple — no seed phrases needed.
849
+ *
850
+ * Docs: https://docs.starknet.io/build/starkzap/react-native
851
+ * https://docs.privy.io/reference/react-native-sdk
852
+ */
853
+
854
+ import { useState, useCallback } from "react";
855
+ import { usePrivy } from "@privy-io/expo";
856
+ import { OnboardStrategy, accountPresets } from "starkzap-native";
857
+ import { sdk } from "@/lib/starkzap";
858
+
859
+ export type WalletState = {
860
+ wallet: Awaited<ReturnType<typeof sdk.connectWallet>> | null;
861
+ address: string | null;
862
+ isConnecting: boolean;
863
+ isReady: boolean;
864
+ error: string | null;
865
+ connect: () => Promise<void>;
866
+ disconnect: () => void;
867
+ };
868
+
869
+ /**
870
+ * Your backend signing endpoint URL.
871
+ * This must be a deployed server — it signs Starknet tx hashes using your
872
+ * Privy server SDK (same /api/wallet/sign pattern as web, just hosted separately).
873
+ *
874
+ * For local dev, you can run an Express server on your machine and use
875
+ * ngrok to expose it: https://ngrok.com
876
+ */
877
+ const SIGNING_SERVER_URL = process.env.EXPO_PUBLIC_SIGNING_SERVER_URL ?? "https://your-api.example.com/api/wallet/sign";
878
+ const WALLET_SERVER_URL = process.env.EXPO_PUBLIC_WALLET_SERVER_URL ?? "https://your-api.example.com/api/wallet/starknet";
879
+
880
+ export function useWallet(): WalletState {
881
+ const { getAccessToken, logout } = usePrivy();
882
+
883
+ const [wallet, setWallet] = useState<WalletState["wallet"]>(null);
884
+ const [address, setAddress] = useState<string | null>(null);
885
+ const [isConnecting, setIsConnecting] = useState(false);
886
+ const [isReady, setIsReady] = useState(false);
887
+ const [error, setError] = useState<string | null>(null);
888
+
889
+ const connect = useCallback(async () => {
890
+ setIsConnecting(true);
891
+ setError(null);
892
+
893
+ try {
894
+ // 1. Get Privy access token from the Expo Privy hook
895
+ const accessToken = await getAccessToken();
896
+ if (!accessToken) throw new Error("Not authenticated — call privy.login() first");
897
+
898
+ // 2. Ask your backend for (or create) the user's Starknet wallet
899
+ const walletRes = await fetch(WALLET_SERVER_URL, {
900
+ method: "POST",
901
+ headers: {
902
+ "Content-Type": "application/json",
903
+ Authorization: \`Bearer \${accessToken}\`,
904
+ },
905
+ });
906
+ if (!walletRes.ok) throw new Error("Failed to get wallet from server");
907
+ const { wallet: privyWallet } = await walletRes.json();
908
+
909
+ // 3. Connect with Starkzap via Privy onboard strategy
910
+ // deploy: "if_needed" → auto-deploys the account on first use
911
+ const onboard = await sdk.onboard({
912
+ strategy: OnboardStrategy.Privy,
913
+ accountPreset: accountPresets.argentXV050,
914
+ privy: {
915
+ resolve: async () => ({
916
+ walletId: privyWallet.id,
917
+ publicKey: privyWallet.publicKey,
918
+ serverUrl: SIGNING_SERVER_URL,
919
+ }),
920
+ },
921
+ deploy: "if_needed",
922
+ });
923
+
924
+ setWallet(onboard.wallet);
925
+ setAddress(privyWallet.address);
926
+ setIsReady(true);
927
+ } catch (err) {
928
+ setError(err instanceof Error ? err.message : "Connection failed");
929
+ } finally {
930
+ setIsConnecting(false);
931
+ }
932
+ }, [getAccessToken]);
933
+
934
+ const disconnect = useCallback(async () => {
935
+ await logout();
936
+ setWallet(null);
937
+ setAddress(null);
938
+ setIsReady(false);
939
+ setError(null);
940
+ }, [logout]);
941
+
942
+ return { wallet, address, isConnecting, isReady, error, connect, disconnect };
943
+ }
944
+ `;
945
+ }
946
+
947
+ function genExpoRootLayout(config) {
948
+ return `import { Stack } from "expo-router";
949
+ import { PrivyProvider } from "@privy-io/expo";
950
+ import { StatusBar } from "expo-status-bar";
951
+
952
+ const PRIVY_APP_ID = process.env.EXPO_PUBLIC_PRIVY_APP_ID ?? "";
953
+
954
+ export default function RootLayout() {
955
+ return (
956
+ <PrivyProvider appId={PRIVY_APP_ID}>
957
+ <StatusBar style="light" />
958
+ <Stack
959
+ screenOptions={{
960
+ headerStyle: { backgroundColor: "#04060f" },
961
+ headerTintColor: "#e2e8ff",
962
+ headerTitleStyle: { fontWeight: "600" },
963
+ contentStyle: { backgroundColor: "#04060f" },
964
+ }}
965
+ />
966
+ </PrivyProvider>
967
+ );
968
+ }
969
+ `;
970
+ }
971
+
972
+ function genExpoHomeScreen(config) {
973
+ return `import {
974
+ View, Text, StyleSheet, TouchableOpacity, ScrollView,
975
+ ActivityIndicator, Alert,
976
+ } from "react-native";
977
+ import { usePrivy } from "@privy-io/expo";
978
+ import { useWallet } from "@/hooks/useWallet";
979
+ import { useTokenBalance } from "@/hooks/useTokenBalance";
980
+ import { network } from "@/lib/starkzap";
981
+
982
+ // ─── Colours (matches web dark theme) ────────────────────────────
983
+ const COLORS = {
984
+ bg: "#04060f",
985
+ surface: "#0a1152",
986
+ border: "#111d87",
987
+ text: "#e2e8ff",
988
+ muted: "#4d78ff",
989
+ dim: "#1a47fb",
990
+ accent: "#ff6b35",
991
+ green: "#34d399",
992
+ red: "#f87171",
993
+ };
994
+
995
+ export default function HomeScreen() {
996
+ const { login, ready, authenticated } = usePrivy();
997
+ const walletState = useWallet();
998
+ const { wallet, address, isConnecting, error, connect, disconnect } = walletState;
999
+
1000
+ const strk = useTokenBalance(wallet, "STRK");
1001
+ const eth = useTokenBalance(wallet, "ETH");
1002
+ const usdc = useTokenBalance(wallet, "USDC");
1003
+
1004
+ const handleConnect = async () => {
1005
+ if (!authenticated) {
1006
+ // Login with Privy first (email, Google, Apple)
1007
+ await login();
1008
+ }
1009
+ // Then connect Starknet wallet
1010
+ await connect();
1011
+ };
1012
+
1013
+ if (!ready) {
1014
+ return (
1015
+ <View style={styles.center}>
1016
+ <ActivityIndicator color={COLORS.muted} />
1017
+ </View>
1018
+ );
1019
+ }
1020
+
1021
+ return (
1022
+ <ScrollView style={styles.scroll} contentContainerStyle={styles.container}>
1023
+ {/* Header */}
1024
+ <View style={styles.header}>
1025
+ <Text style={styles.logo}>⚡ starkzap</Text>
1026
+ <View style={styles.networkBadge}>
1027
+ <Text style={styles.networkText}>{network}</Text>
1028
+ </View>
1029
+ </View>
1030
+
1031
+ {/* Hero */}
1032
+ <Text style={styles.title}>Build on Starknet.{"\n"}Ship in minutes.</Text>
1033
+ <Text style={styles.subtitle}>
1034
+ Powered by Starkzap SDK — wallet, gasless transactions, and DeFi in one package.
1035
+ </Text>
1036
+
1037
+ {error && (
1038
+ <View style={styles.errorBox}>
1039
+ <Text style={styles.errorText}>{error}</Text>
1040
+ </View>
1041
+ )}
1042
+
1043
+ {!address ? (
1044
+ /* ── Not connected ── */
1045
+ <View style={styles.connectBox}>
1046
+ <Text style={styles.connectEmoji}>🔌</Text>
1047
+ <Text style={styles.connectTitle}>Connect your wallet</Text>
1048
+ <Text style={styles.connectSub}>
1049
+ Sign in with email, Google, or Apple via Privy — no seed phrase needed.
1050
+ </Text>
1051
+ <TouchableOpacity
1052
+ style={[styles.btn, isConnecting && styles.btnDisabled]}
1053
+ onPress={handleConnect}
1054
+ disabled={isConnecting}
1055
+ >
1056
+ {isConnecting ? (
1057
+ <ActivityIndicator color="#fff" size="small" />
1058
+ ) : (
1059
+ <Text style={styles.btnText}>Connect Wallet</Text>
1060
+ )}
1061
+ </TouchableOpacity>
1062
+ </View>
1063
+ ) : (
1064
+ /* ── Connected ── */
1065
+ <View style={styles.connectedSection}>
1066
+ {/* Address banner */}
1067
+ <View style={styles.addressBar}>
1068
+ <View style={styles.dot} />
1069
+ <Text style={styles.addressText} numberOfLines={1}>
1070
+ {address.slice(0, 10)}…{address.slice(-6)}
1071
+ </Text>
1072
+ <TouchableOpacity onPress={disconnect} style={styles.disconnectBtn}>
1073
+ <Text style={styles.disconnectText}>Disconnect</Text>
1074
+ </TouchableOpacity>
1075
+ </View>
1076
+
1077
+ {/* Token balances */}
1078
+ <Text style={styles.sectionLabel}>Balances</Text>
1079
+ {[
1080
+ { label: "STRK", icon: "⚡", state: strk, color: "#86a8ff" },
1081
+ { label: "ETH", icon: "Ξ", state: eth, color: "#a5b4fc" },
1082
+ { label: "USDC", icon: "$", state: usdc, color: "#34d399" },
1083
+ ].map(({ label, icon, state, color }) => (
1084
+ <View key={label} style={styles.balanceCard}>
1085
+ <Text style={styles.balanceLabel}>{label}</Text>
1086
+ <Text style={[styles.balanceValue, { color }]}>
1087
+ {state.isLoading ? "——.————" : \`\${icon} \${state.formatted ?? "—"}\`}
1088
+ </Text>
1089
+ <TouchableOpacity onPress={state.refetch} disabled={state.isLoading}>
1090
+ <Text style={styles.refreshText}>↻ refresh</Text>
1091
+ </TouchableOpacity>
1092
+ </View>
1093
+ ))}
1094
+
1095
+ {/* Gasless payment CTA */}
1096
+ <Text style={styles.sectionLabel}>Send Tokens</Text>
1097
+ <View style={styles.paymentCard}>
1098
+ <Text style={styles.paymentText}>
1099
+ Use the gasless payment form to send STRK, ETH, or USDC to any Starknet address — AVNU Paymaster covers the gas fee.
1100
+ </Text>
1101
+ <TouchableOpacity
1102
+ style={styles.btn}
1103
+ onPress={() => Alert.alert("Coming soon", "Wire up the PaymentScreen to send tokens.")}
1104
+ >
1105
+ <Text style={styles.btnText}>Open Payment Form →</Text>
1106
+ </TouchableOpacity>
1107
+ </View>
1108
+ </View>
1109
+ )}
1110
+ </ScrollView>
1111
+ );
1112
+ }
1113
+
1114
+ const styles = StyleSheet.create({
1115
+ scroll: { flex: 1, backgroundColor: COLORS.bg },
1116
+ container: { padding: 24, paddingBottom: 48 },
1117
+ center: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: COLORS.bg },
1118
+ header: { flexDirection: "row", alignItems: "center", gap: 12, marginBottom: 32 },
1119
+ logo: { fontSize: 20, fontWeight: "700", color: COLORS.text },
1120
+ networkBadge:{ backgroundColor: COLORS.surface, borderWidth: 1, borderColor: COLORS.border, borderRadius: 99, paddingHorizontal: 10, paddingVertical: 3 },
1121
+ networkText: { fontSize: 11, color: COLORS.muted, fontFamily: "monospace" },
1122
+ title: { fontSize: 32, fontWeight: "800", color: COLORS.text, lineHeight: 42, marginBottom: 12 },
1123
+ subtitle: { fontSize: 15, color: "#4d78ff", lineHeight: 22, marginBottom: 24 },
1124
+ errorBox: { backgroundColor: "#2d0a0a", borderWidth: 1, borderColor: "#7f1d1d", borderRadius: 12, padding: 14, marginBottom: 16 },
1125
+ errorText: { color: COLORS.red, fontSize: 13 },
1126
+ connectBox: { borderWidth: 1, borderStyle: "dashed", borderColor: COLORS.border, borderRadius: 20, padding: 32, alignItems: "center", gap: 12 },
1127
+ connectEmoji:{ fontSize: 40 },
1128
+ connectTitle:{ fontSize: 18, fontWeight: "700", color: COLORS.text },
1129
+ connectSub: { fontSize: 13, color: COLORS.muted, textAlign: "center", lineHeight: 20 },
1130
+ btn: { backgroundColor: COLORS.dim, borderRadius: 99, paddingHorizontal: 24, paddingVertical: 14, alignItems: "center", width: "100%", marginTop: 8 },
1131
+ btnDisabled: { opacity: 0.5 },
1132
+ btnText: { color: "#fff", fontWeight: "700", fontSize: 15 },
1133
+ connectedSection: { gap: 16 },
1134
+ addressBar: { flexDirection: "row", alignItems: "center", gap: 10, backgroundColor: COLORS.surface, borderWidth: 1, borderColor: COLORS.border, borderRadius: 14, padding: 14 },
1135
+ dot: { width: 8, height: 8, borderRadius: 99, backgroundColor: COLORS.green },
1136
+ addressText: { flex: 1, color: COLORS.text, fontFamily: "monospace", fontSize: 13 },
1137
+ disconnectBtn:{ backgroundColor: "#1a0a0a", borderRadius: 8, paddingHorizontal: 10, paddingVertical: 5 },
1138
+ disconnectText:{ color: COLORS.red, fontSize: 12 },
1139
+ sectionLabel:{ fontSize: 11, fontWeight: "600", letterSpacing: 2, color: COLORS.muted, textTransform: "uppercase", marginTop: 8 },
1140
+ balanceCard: { backgroundColor: COLORS.surface, borderWidth: 1, borderColor: COLORS.border, borderRadius: 16, padding: 18, gap: 4 },
1141
+ balanceLabel:{ fontSize: 11, color: COLORS.muted, letterSpacing: 2, textTransform: "uppercase", fontFamily: "monospace" },
1142
+ balanceValue:{ fontSize: 28, fontWeight: "800", fontFamily: "monospace" },
1143
+ refreshText: { fontSize: 12, color: COLORS.muted, marginTop: 4 },
1144
+ paymentCard: { backgroundColor: COLORS.surface, borderWidth: 1, borderColor: COLORS.border, borderRadius: 16, padding: 20, gap: 14 },
1145
+ paymentText: { fontSize: 14, color: COLORS.muted, lineHeight: 21 },
1146
+ });
1147
+ `;
1148
+ }
1149
+
1150
+ function genExpoTokenBalanceHook() {
1151
+ return `/**
1152
+ * hooks/useTokenBalance.ts — ERC-20 balance fetcher for Expo.
1153
+ * Same logic as web — no "use client" directive needed in React Native.
1154
+ */
1155
+ import { useState, useEffect, useCallback } from "react";
1156
+ import { getPresets, Amount } from "starkzap-native";
1157
+ import type { WalletState } from "./useWallet";
1158
+
1159
+ export type TokenSymbol = "STRK" | "ETH" | "USDC";
1160
+
1161
+ export function useTokenBalance(wallet: WalletState["wallet"], symbol: TokenSymbol = "STRK") {
1162
+ const [formatted, setFormatted] = useState<string | null>(null);
1163
+ const [isLoading, setIsLoading] = useState(false);
1164
+ const [error, setError] = useState<string | null>(null);
1165
+
1166
+ const fetchBalance = useCallback(async () => {
1167
+ if (!wallet) return;
1168
+ setIsLoading(true); setError(null);
1169
+ try {
1170
+ const presets = getPresets(wallet.getChainId());
1171
+ const token = presets[symbol as keyof typeof presets];
1172
+ if (!token) throw new Error(\`Token \${symbol} not found\`);
1173
+ const raw = await wallet.getBalance(token);
1174
+ const amount = Amount.fromBase(raw, token);
1175
+ setFormatted(amount.toFixed(4));
1176
+ } catch (err) {
1177
+ setError(err instanceof Error ? err.message : "Failed to fetch balance");
1178
+ } finally {
1179
+ setIsLoading(false);
1180
+ }
1181
+ }, [wallet, symbol]);
1182
+
1183
+ useEffect(() => { fetchBalance(); }, [fetchBalance]);
1184
+ return { formatted, isLoading, error, symbol, refetch: fetchBalance };
1185
+ }
1186
+ `;
1187
+ }
1188
+
1189
+ function genExpoReadme(config) {
1190
+ return `# ⚡ ${config.projectName} (Expo)
1191
+
1192
+ Scaffolded with **create-starkzap-app** — an Expo (React Native) starter for building on Starknet with the [Starkzap SDK](https://docs.starknet.io/build/starkzap/overview).
1193
+
1194
+ ## Stack
1195
+
1196
+ | | |
1197
+ |---|---|
1198
+ | **Framework** | Expo (React Native) with Expo Router |
1199
+ | **Wallet** | Privy — social/email login via \`@privy-io/expo\` |
1200
+ | **Network** | ${config.network} |
1201
+ | **SDK** | \`starkzap-native\` (React Native build of Starkzap v2) |
1202
+
1203
+ ## Getting Started
1204
+
1205
+ ### 1. Install dependencies
1206
+
1207
+ \`\`\`bash
1208
+ npm install
1209
+ \`\`\`
1210
+
1211
+ ### 2. Set up environment variables
1212
+
1213
+ \`\`\`bash
1214
+ cp .env.example .env
1215
+ \`\`\`
1216
+
1217
+ Fill in:
1218
+ - \`EXPO_PUBLIC_PRIVY_APP_ID\` — from [privy.io](https://privy.io) → create an **Expo** app
1219
+ - \`EXPO_PUBLIC_STARKNET_RPC_URL\` — free at [blastapi.io](https://blastapi.io)
1220
+ - \`EXPO_PUBLIC_SIGNING_SERVER_URL\` — your backend signing endpoint (see below)
1221
+
1222
+ ### 3. Run
1223
+
1224
+ \`\`\`bash
1225
+ npx expo start
1226
+ \`\`\`
1227
+
1228
+ Scan the QR code with **Expo Go** on your phone, or press \`i\` for iOS simulator / \`a\` for Android.
1229
+
1230
+ ---
1231
+
1232
+ ## Wallet Setup (Privy)
1233
+
1234
+ Users sign in with **email, Google, or Apple**. Keys are managed server-side by Privy.
1235
+
1236
+ **How the flow works:**
1237
+ 1. User taps Connect → Privy login sheet appears
1238
+ 2. User authenticates → \`getAccessToken()\` returns a JWT
1239
+ 3. App calls your backend → backend creates/retrieves a Privy Starknet wallet
1240
+ 4. App calls Starkzap's \`OnboardStrategy.Privy\` → wallet is connected
1241
+ 5. Transactions are signed server-side via your signing endpoint
1242
+
1243
+ **You need a backend.** The signing server is a simple Express endpoint — see the [Privy docs](https://docs.starknet.io/build/starkzap/integrations/privy) for the full server code. For local development, use [ngrok](https://ngrok.com) to expose it.
1244
+
1245
+ ### Privy app setup
1246
+ 1. Go to [privy.io](https://privy.io) → New app
1247
+ 2. Select **React Native / Expo** as the platform
1248
+ 3. Enable the login methods you want (email, Google, Apple)
1249
+ 4. Copy the **App ID** to \`.env\`
1250
+
1251
+ ---
1252
+
1253
+ ## Project Structure
1254
+
1255
+ \`\`\`
1256
+ ${config.projectName}/
1257
+ ├── app/
1258
+ │ ├── _layout.tsx # Root layout — wraps app with PrivyProvider
1259
+ │ └── index.tsx # Home screen — wallet connect + balances
1260
+ ├── hooks/
1261
+ │ ├── useWallet.ts # Privy + Starkzap connection hook
1262
+ │ └── useTokenBalance.ts # Live ERC-20 balance fetcher
1263
+ ├── lib/
1264
+ │ └── starkzap.ts # SDK instance (starkzap-native)
1265
+ ├── metro.config.js # Metro config with withStarkzap()
1266
+ ├── app.json # Expo config
1267
+ └── .env.example # Environment variable template
1268
+ \`\`\`
1269
+
1270
+ ---
1271
+
1272
+ ## Key Difference from Web
1273
+
1274
+ | | Web (\`starkzap\`) | Expo (\`starkzap-native\`) |
1275
+ |---|---|---|
1276
+ | Import | \`from "starkzap"\` | \`from "starkzap-native"\` |
1277
+ | Metro config | Not needed | \`withStarkzap(config)\` required |
1278
+ | Privy SDK | \`@privy-io/react-auth\` | \`@privy-io/expo\` |
1279
+ | Env prefix | \`NEXT_PUBLIC_\` | \`EXPO_PUBLIC_\` |
1280
+ | Styling | Tailwind CSS | React Native \`StyleSheet\` |
1281
+
1282
+ The Starkzap SDK API (\`sdk.onboard()\`, \`wallet.transfer()\`, \`wallet.getBalance()\` etc.) is identical.
1283
+
1284
+ ---
1285
+
1286
+ ## Resources
1287
+
1288
+ - [Starkzap React Native Docs](https://docs.starknet.io/build/starkzap/react-native)
1289
+ - [Starkzap SDK Docs](https://docs.starknet.io/build/starkzap/overview)
1290
+ - [Privy Expo SDK](https://docs.privy.io/reference/react-native-sdk)
1291
+ - [Expo Router Docs](https://expo.github.io/router/docs)
1292
+ - [Starkscan Explorer](https://${config.network === "mainnet" ? "" : "sepolia."}starkscan.co)
1293
+
1294
+ ---
1295
+
1296
+ Built with [Starkzap SDK](https://docs.starknet.io/build/starkzap/overview) · Deployed on **Starknet ${config.network}**
1297
+ `;
1298
+ }
1299
+
1300
+ async function scaffoldExpo(config, dir) {
1301
+ writeFile(dir, "package.json", genExpoPackageJson(config));
1302
+ writeFile(dir, "tsconfig.json", genExpoTsConfig());
1303
+ writeFile(dir, "metro.config.js", genExpoMetroConfig());
1304
+ writeFile(dir, "app.json", genExpoAppJson(config));
1305
+ writeFile(dir, "babel.config.js", genExpoBabelConfig());
1306
+ writeFile(dir, ".env.example", genExpoEnvExample(config));
1307
+ writeFile(dir, ".gitignore", `node_modules/\n.expo/\ndist/\n.env\n*.tsbuildinfo\n.DS_Store`);
1308
+ writeFile(dir, "README.md", genExpoReadme(config));
1309
+
1310
+ // SDK lib
1311
+ writeFile(dir, "lib/starkzap.ts", genExpoStarkzapLib(config));
1312
+
1313
+ // Hooks
1314
+ writeFile(dir, "hooks/useWallet.ts", genExpoUseWallet());
1315
+ writeFile(dir, "hooks/useTokenBalance.ts", genExpoTokenBalanceHook());
1316
+
1317
+ // App screens
1318
+ writeFile(dir, "app/_layout.tsx", genExpoRootLayout(config));
1319
+ writeFile(dir, "app/index.tsx", genExpoHomeScreen(config));
1320
+ }
1321
+
1322
+ // ─── Scaffold files ──────────────────────────────────────────────────────────
1323
+
1324
+ function writeFile(dir, filePath, content) {
1325
+ const full = join(dir, filePath);
1326
+ const folder = full.split("/").slice(0, -1).join("/");
1327
+ if (!existsSync(folder)) mkdirSync(folder, { recursive: true });
1328
+ writeFileSync(full, content, "utf8");
1329
+ }
1330
+
1331
+ async function scaffold(config) {
1332
+ const dir = resolve(process.cwd(), config.projectName);
1333
+
1334
+ if (existsSync(dir)) {
1335
+ const overwrite = await confirm(
1336
+ ` ${yellow("⚠")} Directory "${config.projectName}" already exists. Overwrite?`,
1337
+ false
1338
+ );
1339
+ if (!overwrite) {
1340
+ console.log(red("\n Aborted.\n"));
1341
+ process.exit(1);
1342
+ }
1343
+ }
1344
+
1345
+ mkdirSync(dir, { recursive: true });
1346
+
1347
+ // ── Expo path ────────────────────────────────────────────────
1348
+ if (config.framework.id === "expo") {
1349
+ await scaffoldExpo(config, dir);
1350
+ return;
1351
+ }
1352
+
1353
+ const isNextjs = config.framework.id.startsWith("nextjs");
1354
+
1355
+ // Core files
1356
+ writeFile(dir, "package.json", genPackageJson(config));
1357
+ writeFile(dir, "tsconfig.json", JSON.stringify({
1358
+ compilerOptions: {
1359
+ lib: ["dom", "dom.iterable", "esnext"],
1360
+ allowJs: true, skipLibCheck: true, strict: true, noEmit: true,
1361
+ esModuleInterop: true, module: "esnext", moduleResolution: "bundler",
1362
+ resolveJsonModule: true, isolatedModules: true, jsx: "preserve",
1363
+ incremental: true, plugins: isNextjs ? [{ name: "next" }] : [],
1364
+ paths: { "@/*": ["./src/*"] },
1365
+ },
1366
+ include: ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
1367
+ exclude: ["node_modules"],
1368
+ }, null, 2));
1369
+
1370
+ writeFile(dir, ".env.example", genEnvExample(config));
1371
+ writeFile(dir, ".gitignore", `node_modules/\n.next/\ndist/\n.env\n.env.local\n*.tsbuildinfo\n.DS_Store`);
1372
+ writeFile(dir, "tailwind.config.js", `/** @type {import('tailwindcss').Config} */
1373
+ module.exports = {
1374
+ content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
1375
+ theme: {
1376
+ extend: {
1377
+ colors: {
1378
+ stark: {
1379
+ 50: "#f0f4ff", 100: "#dce6ff", 200: "#b9cdff", 300: "#86a8ff",
1380
+ 400: "#4d78ff", 500: "#1a47fb", 600: "#0c2ef0", 700: "#0920d4",
1381
+ 800: "#0d1dac", 900: "#111d87", 950: "#0a1152",
1382
+ },
1383
+ accent: "#ff6b35",
1384
+ },
1385
+ fontFamily: {
1386
+ mono: ["'IBM Plex Mono'", "monospace"],
1387
+ sans: ["'DM Sans'", "sans-serif"],
1388
+ },
1389
+ animation: { "fade-in": "fadeIn 0.4s ease forwards" },
1390
+ keyframes: { fadeIn: { from: { opacity: "0", transform: "translateY(6px)" }, to: { opacity: "1", transform: "translateY(0)" } } },
1391
+ },
1392
+ },
1393
+ plugins: [],
1394
+ };`);
1395
+ writeFile(dir, "postcss.config.js", `module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } };`);
1396
+
1397
+ // Framework-specific config
1398
+ if (isNextjs) {
1399
+ writeFile(dir, "next.config.mjs", genNextConfig(config));
1400
+ } else {
1401
+ writeFile(dir, "vite.config.ts", genViteConfig());
1402
+ writeFile(dir, "index.html", `<!DOCTYPE html>
1403
+ <html lang="en">
1404
+ <head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" />
1405
+ <title>Starkzap App</title></head>
1406
+ <body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
1407
+ </html>`);
1408
+ }
1409
+
1410
+ // Library files
1411
+ writeFile(dir, "src/lib/starkzap.ts", genStarkzapLib(config));
1412
+ writeFile(dir, "src/lib/utils.ts", `import { clsx, type ClassValue } from "clsx";
1413
+ import { twMerge } from "tailwind-merge";
1414
+ export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
1415
+ export const formatAddress = (a: string, chars = 4) => \`\${a.slice(0, chars + 2)}...\${a.slice(-chars)}\`;
1416
+ export const explorerUrl = (v: string, type: "address" | "tx" = "address", net = "sepolia") =>
1417
+ \`https://\${net === "mainnet" ? "" : "sepolia."}starkscan.co/\${type}/\${v}\`;
1418
+ `);
1419
+
1420
+ // Wallet hook — differs by wallet choice
1421
+ writeFile(dir, "src/hooks/useWallet.ts",
1422
+ config.wallet === "privy" ? genUseWalletPrivy() : genUseWalletCartridge()
1423
+ );
1424
+
1425
+ // Privy API routes (Next.js only)
1426
+ if (config.wallet === "privy" && isNextjs) {
1427
+ writeFile(dir, "src/app/api/wallet/starknet/route.ts", genPrivyApiRoute());
1428
+ writeFile(dir, "src/app/api/wallet/sign/route.ts", genPrivySignRoute());
1429
+ }
1430
+
1431
+ // Shared hooks
1432
+ writeFile(dir, "src/hooks/useTokenBalance.ts", `"use client";
1433
+ import { useState, useEffect, useCallback } from "react";
1434
+ import { getPresets, Amount } from "starkzap";
1435
+ import type { WalletState } from "./useWallet";
1436
+
1437
+ export type TokenSymbol = "STRK" | "ETH" | "USDC";
1438
+
1439
+ export function useTokenBalance(wallet: WalletState["wallet"], symbol: TokenSymbol = "STRK") {
1440
+ const [formatted, setFormatted] = useState<string | null>(null);
1441
+ const [isLoading, setIsLoading] = useState(false);
1442
+ const [error, setError] = useState<string | null>(null);
1443
+
1444
+ const fetchBalance = useCallback(async () => {
1445
+ if (!wallet) return;
1446
+ setIsLoading(true); setError(null);
1447
+ try {
1448
+ const presets = getPresets(wallet.getChainId());
1449
+ const token = presets[symbol as keyof typeof presets];
1450
+ if (!token) throw new Error(\`Token \${symbol} not found\`);
1451
+ const raw = await wallet.getBalance(token);
1452
+ const amount = Amount.fromBase(raw, token);
1453
+ setFormatted(amount.toFixed(4));
1454
+ } catch (err) {
1455
+ setError(err instanceof Error ? err.message : "Failed");
1456
+ } finally { setIsLoading(false); }
1457
+ }, [wallet, symbol]);
1458
+
1459
+ useEffect(() => { fetchBalance(); }, [fetchBalance]);
1460
+ return { formatted, isLoading, error, symbol, refetch: fetchBalance };
1461
+ }`);
1462
+
1463
+ writeFile(dir, "src/hooks/useGaslessTransfer.ts", `"use client";
1464
+ import { useState, useCallback } from "react";
1465
+ import { getPresets, Amount } from "starkzap";
1466
+ import type { WalletState } from "./useWallet";
1467
+
1468
+ export function useGaslessTransfer(wallet: WalletState["wallet"]) {
1469
+ const [txHash, setTxHash] = useState<string | null>(null);
1470
+ const [isPending, setIsPending] = useState(false);
1471
+ const [isSuccess, setIsSuccess] = useState(false);
1472
+ const [error, setError] = useState<string | null>(null);
1473
+
1474
+ const send = useCallback(async ({ to, amount, symbol }: { to: string; amount: string; symbol: "STRK" | "ETH" | "USDC" }) => {
1475
+ if (!wallet) { setError("Wallet not connected"); return; }
1476
+ setIsPending(true); setError(null); setIsSuccess(false); setTxHash(null);
1477
+ try {
1478
+ const presets = getPresets(wallet.getChainId());
1479
+ const token = presets[symbol as keyof typeof presets];
1480
+ if (!token) throw new Error(\`Token \${symbol} not available\`);
1481
+ const tx = await wallet.transfer({ to, token, amount: Amount.parse(amount, token) }, { feeMode: "sponsored" });
1482
+ setTxHash(tx.hash);
1483
+ await tx.wait();
1484
+ setIsSuccess(true);
1485
+ } catch (err) { setError(err instanceof Error ? err.message : "Transaction failed"); }
1486
+ finally { setIsPending(false); }
1487
+ }, [wallet]);
1488
+
1489
+ const reset = useCallback(() => { setTxHash(null); setIsPending(false); setIsSuccess(false); setError(null); }, []);
1490
+ return { send, txHash, isPending, isSuccess, error, reset };
1491
+ }`);
1492
+
1493
+ // Shared components (same for both wallet types)
1494
+ writeFile(dir, "src/components/wallet/WalletButton.tsx", `"use client";
1495
+ import { cn, formatAddress } from "@/lib/utils";
1496
+ import type { WalletState } from "@/hooks/useWallet";
1497
+
1498
+ export function WalletButton({ walletState, className }: { walletState: WalletState; className?: string }) {
1499
+ const { address, isConnecting, connect, disconnect } = walletState;
1500
+ if (address) return (
1501
+ <button onClick={disconnect} className={cn("group flex items-center gap-2 rounded-full border border-stark-700 bg-stark-900 px-4 py-2 text-sm font-mono text-stark-200 transition-all hover:border-red-500 hover:text-red-400", className)}>
1502
+ <span className="h-2 w-2 rounded-full bg-emerald-400 group-hover:bg-red-400 transition-colors" />
1503
+ {formatAddress(address)}
1504
+ <span className="opacity-0 group-hover:opacity-100 transition-opacity text-xs">disconnect</span>
1505
+ </button>
1506
+ );
1507
+ return (
1508
+ <button onClick={connect} disabled={isConnecting} className={cn("flex items-center gap-2 rounded-full bg-stark-500 px-5 py-2 text-sm font-semibold text-white shadow-lg transition-all hover:bg-stark-400 active:scale-95 disabled:opacity-60", className)}>
1509
+ {isConnecting ? (<><span className="h-3 w-3 animate-spin rounded-full border-2 border-white border-t-transparent" />Connecting…</>) : "Connect Wallet"}
1510
+ </button>
1511
+ );
1512
+ }`);
1513
+
1514
+ writeFile(dir, "src/components/wallet/TokenBalanceCard.tsx", `"use client";
1515
+ import { useTokenBalance, type TokenSymbol } from "@/hooks/useTokenBalance";
1516
+ import type { WalletState } from "@/hooks/useWallet";
1517
+ import { cn } from "@/lib/utils";
1518
+ const META: Record<TokenSymbol, { icon: string; color: string }> = {
1519
+ STRK: { icon: "⚡", color: "text-stark-300" },
1520
+ ETH: { icon: "Ξ", color: "text-indigo-300" },
1521
+ USDC: { icon: "$", color: "text-emerald-300" },
1522
+ };
1523
+ export function TokenBalanceCard({ wallet, symbol = "STRK", className }: { wallet: WalletState["wallet"]; symbol?: TokenSymbol; className?: string }) {
1524
+ const { formatted, isLoading, error, refetch } = useTokenBalance(wallet, symbol);
1525
+ const meta = META[symbol];
1526
+ return (
1527
+ <div className={cn("rounded-2xl border border-stark-800 bg-stark-950/60 p-5 backdrop-blur-sm", className)}>
1528
+ <div className="flex items-center justify-between mb-3">
1529
+ <span className="text-xs font-mono uppercase tracking-widest text-stark-400">{symbol} Balance</span>
1530
+ <button onClick={refetch} disabled={isLoading || !wallet} className="rounded-full p-1.5 text-stark-500 hover:text-stark-200 hover:bg-stark-800 transition-colors disabled:opacity-40" title="Refresh">
1531
+ <svg className={cn("h-3.5 w-3.5", isLoading && "animate-spin")} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
1532
+ </button>
1533
+ </div>
1534
+ {error ? <p className="text-sm text-red-400">{error}</p> : (
1535
+ <p className={cn("text-3xl font-mono font-bold", meta.color)}>
1536
+ {isLoading || !wallet ? <span className="animate-pulse text-stark-700">——.————</span> : <>{meta.icon} {formatted ?? "—"}</>}
1537
+ </p>
1538
+ )}
1539
+ </div>
1540
+ );
1541
+ }`);
1542
+
1543
+ writeFile(dir, "src/components/payment/PaymentForm.tsx", `"use client";
1544
+ import { useState } from "react";
1545
+ import { useGaslessTransfer } from "@/hooks/useGaslessTransfer";
1546
+ import type { WalletState } from "@/hooks/useWallet";
1547
+ import { cn, explorerUrl } from "@/lib/utils";
1548
+ import { network } from "@/lib/starkzap";
1549
+ const TOKENS = ["STRK", "ETH", "USDC"] as const;
1550
+ export function PaymentForm({ wallet, className }: { wallet: WalletState["wallet"]; className?: string }) {
1551
+ const [to, setTo] = useState(""); const [amount, setAmount] = useState(""); const [symbol, setSymbol] = useState<"STRK"|"ETH"|"USDC">("STRK");
1552
+ const { send, txHash, isPending, isSuccess, error, reset } = useGaslessTransfer(wallet);
1553
+ const isValid = to.startsWith("0x") && to.length >= 60 && Number(amount) > 0;
1554
+ if (isSuccess && txHash) return (
1555
+ <div className={cn("rounded-2xl border border-emerald-800 bg-emerald-950/40 p-6 text-center animate-fade-in", className)}>
1556
+ <div className="text-4xl mb-3">✓</div>
1557
+ <p className="font-semibold text-emerald-300 mb-1">Payment sent!</p>
1558
+ <p className="text-sm text-stark-400 mb-4">{amount} {symbol} sent gaslessly</p>
1559
+ <a href={explorerUrl(txHash, "tx", network)} target="_blank" rel="noopener noreferrer" className="text-xs text-stark-400 underline hover:text-stark-200 font-mono">View on Starkscan ↗</a>
1560
+ <button onClick={reset} className="mt-4 block w-full rounded-xl bg-stark-800 py-2 text-sm text-stark-300 hover:bg-stark-700 transition-colors">Send another</button>
1561
+ </div>
1562
+ );
1563
+ return (
1564
+ <form onSubmit={async e => { e.preventDefault(); if (isValid) await send({ to, amount, symbol }); }} className={cn("rounded-2xl border border-stark-800 bg-stark-950/60 p-5 backdrop-blur-sm space-y-4", className)}>
1565
+ <h3 className="text-sm font-mono uppercase tracking-widest text-stark-400">Gasless Payment</h3>
1566
+ <div className="flex gap-2">{TOKENS.map(t => <button key={t} type="button" onClick={() => setSymbol(t)} className={cn("flex-1 rounded-xl py-2 text-sm font-semibold transition-all", symbol === t ? "bg-stark-500 text-white" : "bg-stark-900 text-stark-400 hover:bg-stark-800")}>{t}</button>)}</div>
1567
+ <div><label className="mb-1.5 block text-xs text-stark-500">Amount</label><input type="number" min="0" step="any" placeholder="0.00" value={amount} onChange={e => setAmount(e.target.value)} className="w-full rounded-xl bg-stark-900 border border-stark-800 px-4 py-2.5 font-mono text-lg text-white placeholder-stark-700 focus:border-stark-500 focus:outline-none" /></div>
1568
+ <div><label className="mb-1.5 block text-xs text-stark-500">Recipient</label><input type="text" placeholder="0x..." value={to} onChange={e => setTo(e.target.value)} className="w-full rounded-xl bg-stark-900 border border-stark-800 px-4 py-2.5 font-mono text-sm text-white placeholder-stark-700 focus:border-stark-500 focus:outline-none" /></div>
1569
+ {error && <p className="rounded-xl bg-red-950/60 border border-red-900 px-4 py-2 text-sm text-red-400">{error}</p>}
1570
+ <button type="submit" disabled={!isValid || isPending || !wallet} className="w-full rounded-xl bg-stark-500 py-3 font-semibold text-white shadow-lg transition-all hover:bg-stark-400 active:scale-95 disabled:opacity-40">
1571
+ {isPending ? <span className="flex items-center justify-center gap-2"><span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />Sending…</span> : \`Send \${symbol} — No Gas Required\`}
1572
+ </button>
1573
+ <p className="text-center text-xs text-stark-600">Gas sponsored via ${config.wallet === "cartridge" ? "Cartridge Paymaster" : "AVNU Paymaster"}</p>
1574
+ </form>
1575
+ );
1576
+ }`);
1577
+
1578
+ // App pages
1579
+ writeFile(dir, "src/app/globals.css", `@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap');
1580
+ @tailwind base; @tailwind components; @tailwind utilities;
1581
+ body { background-color: #04060f; color: #e2e8ff; font-family: 'DM Sans', sans-serif; -webkit-font-smoothing: antialiased; }
1582
+ .grid-bg { background-image: linear-gradient(rgba(26,71,251,0.04) 1px, transparent 1px), linear-gradient(90deg, rgba(26,71,251,0.04) 1px, transparent 1px); background-size: 40px 40px; }
1583
+ @layer utilities { .text-gradient { background: linear-gradient(135deg, #86a8ff 0%, #ffffff 50%, #ff6b35 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } }`);
1584
+
1585
+ writeFile(dir, "src/app/layout.tsx", `import type { Metadata } from "next";
1586
+ import "./globals.css";
1587
+ export const metadata: Metadata = { title: "${config.projectName}", description: "Built with Starkzap SDK on Starknet" };
1588
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
1589
+ return <html lang="en"><body className="min-h-screen grid-bg antialiased">{children}</body></html>;
1590
+ }`);
1591
+
1592
+ writeFile(dir, "src/app/page.tsx", `"use client";
1593
+ import { useWallet } from "@/hooks/useWallet";
1594
+ import { WalletButton } from "@/components/wallet/WalletButton";
1595
+ import { TokenBalanceCard } from "@/components/wallet/TokenBalanceCard";
1596
+ import { PaymentForm } from "@/components/payment/PaymentForm";
1597
+ import { network } from "@/lib/starkzap";
1598
+
1599
+ export default function Home() {
1600
+ const walletState = useWallet();
1601
+ const { wallet, address, error: walletError } = walletState;
1602
+ return (
1603
+ <div className="min-h-screen flex flex-col">
1604
+ <header className="border-b border-stark-900/80 bg-stark-950/50 backdrop-blur-md sticky top-0 z-50">
1605
+ <div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
1606
+ <div className="flex items-center gap-3">
1607
+ <span className="text-xl">⚡</span>
1608
+ <span className="font-mono font-semibold text-white">starkzap<span className="text-stark-400">-app</span></span>
1609
+ <span className="rounded-full bg-stark-900 border border-stark-800 px-2.5 py-0.5 text-xs font-mono text-stark-400">{network}</span>
1610
+ </div>
1611
+ <WalletButton walletState={walletState} />
1612
+ </div>
1613
+ </header>
1614
+ <section className="mx-auto max-w-5xl px-6 py-16 text-center animate-fade-in">
1615
+ <h1 className="text-5xl font-bold text-gradient mb-5">Build on Starknet.<br/>Ship in minutes.</h1>
1616
+ <p className="mx-auto max-w-xl text-stark-400 text-lg">Powered by the Starkzap SDK — wallet, gasless transactions, and DeFi in one package.</p>
1617
+ {walletError && <div className="mx-auto mt-6 max-w-md rounded-xl border border-red-900 bg-red-950/40 px-4 py-3 text-sm text-red-400">{walletError}</div>}
1618
+ </section>
1619
+ <main className="mx-auto w-full max-w-5xl flex-1 px-6 pb-20">
1620
+ {!address ? (
1621
+ <div className="rounded-2xl border border-dashed border-stark-800 bg-stark-950/40 p-12 text-center">
1622
+ <p className="text-4xl mb-4">🔌</p>
1623
+ <p className="font-semibold text-stark-300 mb-6">Connect your wallet to get started</p>
1624
+ <WalletButton walletState={walletState} className="mx-auto" />
1625
+ </div>
1626
+ ) : (
1627
+ <div className="animate-fade-in space-y-6">
1628
+ <div className="flex items-center gap-3 rounded-xl border border-stark-800 bg-stark-900/40 px-4 py-3">
1629
+ <span className="h-2 w-2 rounded-full bg-emerald-400" />
1630
+ <span className="text-sm text-stark-400">Connected as</span>
1631
+ <span className="font-mono text-sm text-white">{address}</span>
1632
+ </div>
1633
+ <div className="grid gap-6 md:grid-cols-2">
1634
+ <div className="space-y-4">
1635
+ <h2 className="text-xs font-mono uppercase tracking-widest text-stark-500">Balances</h2>
1636
+ <TokenBalanceCard wallet={wallet} symbol="STRK" />
1637
+ <TokenBalanceCard wallet={wallet} symbol="ETH" />
1638
+ <TokenBalanceCard wallet={wallet} symbol="USDC" />
1639
+ </div>
1640
+ <div className="space-y-4">
1641
+ <h2 className="text-xs font-mono uppercase tracking-widest text-stark-500">Gasless Payment</h2>
1642
+ <PaymentForm wallet={wallet} />
1643
+ </div>
1644
+ </div>
1645
+ </div>
1646
+ )}
1647
+ </main>
1648
+ <footer className="border-t border-stark-900 py-6 text-center text-xs text-stark-700 font-mono">
1649
+ Built with <a href="https://github.com/keep-starknet-strange/starkzap" target="_blank" rel="noopener noreferrer" className="text-stark-500 hover:text-stark-300">Starkzap SDK</a> · Starknet {network}
1650
+ </footer>
1651
+ </div>
1652
+ );
1653
+ }`);
1654
+
1655
+ writeFile(dir, "README.md", genReadme(config));
1656
+ }
1657
+
1658
+ // ─── Main ─────────────────────────────────────────────────────────────────────
1659
+
1660
+ async function main() {
1661
+ printBanner();
1662
+
1663
+ // Project name
1664
+ let projectName = process.argv[2];
1665
+ if (!projectName) {
1666
+ const answer = await prompt(`${bold("Project name")} ${gray("(starkzap-app)")} › `);
1667
+ projectName = answer || "starkzap-app";
1668
+ }
1669
+
1670
+ // Framework
1671
+ const framework = await select("Which framework?", [
1672
+ { id: "nextjs-app", label: "Next.js 14 — App Router", hint: "recommended" },
1673
+ { id: "nextjs-pages", label: "Next.js 14 — Pages Router", hint: "" },
1674
+ { id: "vite-react", label: "Vite + React", hint: "lightweight" },
1675
+ { id: "expo", label: "Expo (React Native)", hint: "mobile — iOS & Android" },
1676
+ ]);
1677
+
1678
+ const isExpo = framework.id === "expo";
1679
+
1680
+ // Wallet — Expo always uses Privy, web gets the choice
1681
+ let walletOption;
1682
+ if (isExpo) {
1683
+ console.log(`\n ${cyan("ℹ")} Expo uses ${bold("Privy")} for wallet auth (social/email login via ${cyan("@privy-io/expo")})`);
1684
+ walletOption = { id: "privy" };
1685
+ } else {
1686
+ walletOption = await select("Which wallet integration?", [
1687
+ {
1688
+ id: "privy",
1689
+ label: "Privy — social/email login",
1690
+ hint: "email, Google, Apple — no wallet extension needed",
1691
+ },
1692
+ {
1693
+ id: "cartridge",
1694
+ label: "Cartridge Controller — passkey/social",
1695
+ hint: "Face ID, Twitter, Google — best for games",
1696
+ },
1697
+ ]);
1698
+ }
1699
+
1700
+ // Network
1701
+ const networkOption = await select("Target network?", [
1702
+ { id: "sepolia", label: "Sepolia (testnet)", hint: "start here" },
1703
+ { id: "mainnet", label: "Mainnet", hint: "production" },
1704
+ ]);
1705
+
1706
+ const config = {
1707
+ projectName,
1708
+ framework,
1709
+ wallet: walletOption.id,
1710
+ network: networkOption.id,
1711
+ typescript: true,
1712
+ };
1713
+
1714
+ printSummary(config);
1715
+
1716
+ const go = await confirm("\n Scaffold this project?");
1717
+ if (!go) { console.log(red("\n Aborted.\n")); process.exit(0); }
1718
+
1719
+ console.log(`\n ${cyan("◆")} Scaffolding ${bold(projectName)}…\n`);
1720
+
1721
+ await scaffold(config);
1722
+
1723
+ console.log(`\n ${green("✓")} Project created!\n`);
1724
+ console.log(` ${bold("Next steps:")}`);
1725
+ console.log(` ${gray("1.")} cd ${cyan(projectName)}`);
1726
+ console.log(` ${gray("2.")} cp .env.example .env.local ${gray("# fill in your keys")}`);
1727
+ console.log(` ${gray("3.")} npm install`);
1728
+
1729
+ if (config.framework.id === "expo") {
1730
+ console.log(` ${gray("4.")} npx expo start\n`);
1731
+ console.log(` ${yellow("📱 Expo:")} scan the QR code with Expo Go on your phone`);
1732
+ console.log(` ${yellow("⚡ Privy setup:")} get your App ID at ${cyan("https://privy.io")} → create an Expo app`);
1733
+ console.log(` ${dim(" Privy Expo docs:")} ${cyan("https://docs.privy.io/reference/react-native-sdk")}`);
1734
+ } else {
1735
+ console.log(` ${gray("4.")} npm run dev\n`);
1736
+ if (config.wallet === "privy") {
1737
+ console.log(` ${yellow("⚡ Privy setup:")} get your App ID at ${cyan("https://privy.io")}`);
1738
+ } else {
1739
+ console.log(` ${yellow("⚡ Cartridge setup:")} edit CARTRIDGE_POLICIES in src/hooks/useWallet.ts`);
1740
+ }
1741
+ }
1742
+
1743
+ console.log(`\n ${dim("Docs:")} ${cyan("https://docs.starknet.io/build/starkzap/overview")}\n`);
1744
+ }
1745
+
1746
+ main().catch((err) => {
1747
+ console.error(red(`\n Error: ${err.message}\n`));
1748
+ process.exit(1);
1749
+ });