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.
- package/.env.example +23 -0
- package/README.md +164 -0
- package/cli-package.json +31 -0
- package/next.config.mjs +8 -0
- package/package.json +33 -0
- package/postcss.config.js +6 -0
- package/scripts/create-starkzap-app.mjs +1749 -0
- package/src/app/globals.css +46 -0
- package/src/app/layout.tsx +21 -0
- package/src/app/page.tsx +159 -0
- package/src/components/payment/PaymentForm.tsx +158 -0
- package/src/components/wallet/TokenBalanceCard.tsx +79 -0
- package/src/components/wallet/WalletButton.tsx +59 -0
- package/src/hooks/useGaslessTransfer.ts +88 -0
- package/src/hooks/useTokenBalance.ts +66 -0
- package/src/hooks/useWallet.ts +88 -0
- package/src/lib/starkzap.ts +22 -0
- package/src/lib/utils.ts +54 -0
- package/tailwind.config.js +43 -0
- package/tsconfig.json +22 -0
|
@@ -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
|
+
});
|