From 7c6dd56b71d8f2ac5229912651f76418d5634a45 Mon Sep 17 00:00:00 2001 From: 0xBora <133051383+0xBora@users.noreply.github.com> Date: Sat, 26 Sep 2026 06:30:12 +0200 Subject: [PATCH] fix(examples): keep the Blockfrost key out of the vite-react bundle --- examples/with-vite-react/.env.example | 12 +-- examples/with-vite-react/.gitignore | 2 +- examples/with-vite-react/README.md | 70 ++++++------ examples/with-vite-react/package.json | 5 +- examples/with-vite-react/server/index.ts | 55 ++++++++++ examples/with-vite-react/server/payments.ts | 101 ++++++++++++++++++ .../src/components/TransactionBuilder.tsx | 75 ++++++------- .../src/components/WalletConnect.tsx | 4 +- examples/with-vite-react/src/config.ts | 5 + examples/with-vite-react/src/network.ts | 18 ++++ examples/with-vite-react/src/vite-env.d.ts | 3 +- examples/with-vite-react/tsconfig.json | 3 +- examples/with-vite-react/tsconfig.node.json | 23 ---- examples/with-vite-react/vite.config.ts | 31 +++++- pnpm-lock.yaml | 3 + 15 files changed, 289 insertions(+), 121 deletions(-) create mode 100644 examples/with-vite-react/server/index.ts create mode 100644 examples/with-vite-react/server/payments.ts create mode 100644 examples/with-vite-react/src/config.ts create mode 100644 examples/with-vite-react/src/network.ts delete mode 100644 examples/with-vite-react/tsconfig.node.json diff --git a/examples/with-vite-react/.env.example b/examples/with-vite-react/.env.example index 5a9ab7b9..1ac93fbd 100644 --- a/examples/with-vite-react/.env.example +++ b/examples/with-vite-react/.env.example @@ -1,10 +1,6 @@ -# Network Configuration -# Options: "preprod" (testnet), "preview" (testnet), "mainnet" +# Network the app runs on: preprod, preview or mainnet. Public; not a secret. VITE_NETWORK=preprod -# Blockfrost API Configuration -# Get your free API key from https://blockfrost.io -# For testnet (preprod): Use the preprod project ID -# For preview: Use the preview project ID -# For mainnet: Use the mainnet project ID -VITE_BLOCKFROST_PROJECT_ID=your_blockfrost_project_id_here +# Blockfrost project ID for the network above, from https://blockfrost.io. +# Server-only: no VITE_ prefix, so Vite never puts it in the browser bundle. +BLOCKFROST_PROJECT_ID=your_blockfrost_project_id_here diff --git a/examples/with-vite-react/.gitignore b/examples/with-vite-react/.gitignore index 556cd6e4..38c6f841 100644 --- a/examples/with-vite-react/.gitignore +++ b/examples/with-vite-react/.gitignore @@ -9,7 +9,7 @@ lerna-debug.log* node_modules dist -dist-ssr +dist-server *.local # Environment variables diff --git a/examples/with-vite-react/README.md b/examples/with-vite-react/README.md index 371c111d..76fbeb31 100644 --- a/examples/with-vite-react/README.md +++ b/examples/with-vite-react/README.md @@ -32,8 +32,9 @@ Then edit `.env` and configure your network and Blockfrost project ID: # Choose your network: "preprod", "preview", or "mainnet" VITE_NETWORK=preprod -# Add your Blockfrost project ID for the selected network -VITE_BLOCKFROST_PROJECT_ID=your_blockfrost_project_id_here +# Add your Blockfrost project ID for the selected network. +# Server-only: without the VITE_ prefix, Vite never puts it in the browser bundle. +BLOCKFROST_PROJECT_ID=your_blockfrost_project_id_here ``` **Network Options:** @@ -72,6 +73,9 @@ The app will be available at `http://localhost:5173` ``` with-vite-react/ +├── server/ +│ ├── payments.ts # Payment API: builds and submits transactions +│ └── index.ts # Production server: the built app plus the API ├── src/ │ ├── components/ │ │ ├── Main.tsx # Main container component @@ -101,38 +105,30 @@ with-vite-react/ ## Evolution SDK Integration -The app demonstrates how to use the Evolution SDK for building and submitting transactions: +Vite exposes every `VITE_` variable to the browser, so the Blockfrost key stays on the server. The +app follows the split in the Evolution SDK's wallet security guide: the server builds, the browser +signs. ```typescript -import { client, preprod } from "@evolution-sdk/evolution"; - -// Create a staged client with provider and CIP-30 wallet access -const sdk = client(preprod) - .withBlockfrost({ - baseUrl: "https://cardano-preprod.blockfrost.io/api/v0", - projectId: "your_project_id" - }) - .withCip30(walletApi); - -// Build and submit transaction -const txHash = await sdk +// Browser (src/components/TransactionBuilder.tsx): no provider, only the CIP-30 wallet +const client = Client.make(chain).withCip30(walletApi) +const from = Address.toBech32(await client.address()) +const { txCbor } = await post("/api/build-payment", { from, to, lovelace }) +const witnessSet = await client.signTx(txCbor) +const signedTxCbor = Transaction.addVKeyWitnessesHex(txCbor, TransactionWitnessSet.toCBORHex(witnessSet)) +const { txHash } = await post("/api/submit-tx", { signedTxCbor }) + +// Server (server/payments.ts): the provider, with the key +const tx = await Client.make(chain) + .withBlockfrost({ baseUrl, projectId: process.env.BLOCKFROST_PROJECT_ID }) + .withAddress(from) .newTx() - .payToAddress({ - address: recipientAddress, - assets: { lovelace: 5_000_000n } - }) + .payToAddress({ address: Address.fromBech32(to), assets: Assets.fromLovelace(BigInt(lovelace)) }) .build() - .then(tx => tx.sign()) - .then(tx => tx.submit()); ``` -### Key Concepts - -- **Client Assembly**: Start with `client(chain)` and add capabilities with `.withX(...)` -- **Wallet Capability**: Connect a CIP-30 wallet with `.withCip30(walletApi)` -- **Provider Capability**: Add Blockfrost, Maestro, Kupmios, or Koios with `.withBlockfrost(...)` and the related methods -- **Transaction Building**: Chain operations like `payToAddress()`, `collectFrom()`, etc. -- **Signing & Submission**: Build → Sign → Submit pipeline +`pnpm dev` serves the API from the Vite dev server. In production, `server/index.ts` serves it with +the built app. The API is public, so add rate limiting or an origin check before deploying it. ## Development @@ -140,15 +136,11 @@ const txHash = await sdk ```bash pnpm build +pnpm start ``` -The built files will be in the `dist/` directory. - -### Preview Production Build - -```bash -pnpm preview -``` +`pnpm build` puts the app in `dist/` and the server in `dist-server/`. `pnpm start` serves both on +port 3000 (set `PORT` to change it). ## Environment Configuration @@ -156,7 +148,7 @@ The app uses environment variables to configure the network: ```env VITE_NETWORK=preprod # Network to use -VITE_BLOCKFROST_PROJECT_ID=... # Your Blockfrost API key +BLOCKFROST_PROJECT_ID=... # Your Blockfrost API key (server-only) ``` ### Switching Networks @@ -166,19 +158,19 @@ To switch between networks, update your `.env` file: **For Preprod Testnet (Development):** ```env VITE_NETWORK=preprod -VITE_BLOCKFROST_PROJECT_ID=preprodXXXXXXXXXXXXXXXX +BLOCKFROST_PROJECT_ID=preprodXXXXXXXXXXXXXXXX ``` **For Preview Testnet (Testing):** ```env VITE_NETWORK=preview -VITE_BLOCKFROST_PROJECT_ID=previewXXXXXXXXXXXXXXXX +BLOCKFROST_PROJECT_ID=previewXXXXXXXXXXXXXXXX ``` **For Mainnet (Production):** ```env VITE_NETWORK=mainnet -VITE_BLOCKFROST_PROJECT_ID=mainnetXXXXXXXXXXXXXXXX +BLOCKFROST_PROJECT_ID=mainnetXXXXXXXXXXXXXXXX ``` Restart the dev server after changing the `.env` file. diff --git a/examples/with-vite-react/package.json b/examples/with-vite-react/package.json index b36d11a7..b67ace31 100644 --- a/examples/with-vite-react/package.json +++ b/examples/with-vite-react/package.json @@ -5,9 +5,9 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc && vite build", + "build": "tsc && vite build && vite build --ssr server/index.ts --outDir dist-server", + "start": "node dist-server/index.js", "type-check": "tsc --noEmit", - "preview": "vite preview", "test": "echo \"No tests specified for Vite React example\" && exit 0" }, "dependencies": { @@ -18,6 +18,7 @@ "react-dom": "^19.2.5" }, "devDependencies": { + "@types/node": "^25.7.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@tailwindcss/postcss": "^4.1.7", diff --git a/examples/with-vite-react/server/index.ts b/examples/with-vite-react/server/index.ts new file mode 100644 index 00000000..0480b68e --- /dev/null +++ b/examples/with-vite-react/server/index.ts @@ -0,0 +1,55 @@ +// Production server: serves the built app from dist/ and the payment API. +// Build with `pnpm build`, then run with `pnpm start`. +import { readFile } from "node:fs/promises" +import { createServer } from "node:http" +import { extname, join, normalize } from "node:path" +import { fileURLToPath } from "node:url" + +import { createPaymentApi } from "./payments.ts" + +try { + process.loadEnvFile() +} catch { + // No .env file: use the environment as it is. +} + +const handleApi = createPaymentApi({ + network: process.env.VITE_NETWORK, + blockfrostProjectId: process.env.BLOCKFROST_PROJECT_ID +}) + +const distDir = fileURLToPath(new URL("../dist/", import.meta.url)) +const contentTypes: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript", + ".css": "text/css", + ".svg": "image/svg+xml", + ".png": "image/png", + ".ico": "image/x-icon", + ".wasm": "application/wasm" +} + +async function serveStatic(pathname: string) { + // normalize() plus the prefix check keeps requests inside dist/. + const file = normalize(join(distDir, pathname === "/" ? "index.html" : pathname)) + if (!file.startsWith(distDir)) return undefined + try { + return { body: await readFile(file), type: contentTypes[extname(file)] ?? "application/octet-stream" } + } catch { + return undefined + } +} + +const port = Number(process.env.PORT ?? 3000) + +createServer(async (req, res) => { + if (await handleApi(req, res)) return + const pathname = new URL(req.url ?? "/", "http://localhost").pathname + // Unknown paths get index.html, so client-side routes still load. + const asset = (await serveStatic(pathname)) ?? (await serveStatic("/")) + if (!asset) { + res.writeHead(404).end("Run `pnpm build` first.") + return + } + res.writeHead(200, { "Content-Type": asset.type }).end(asset.body) +}).listen(port, () => console.log(`http://localhost:${port}`)) diff --git a/examples/with-vite-react/server/payments.ts b/examples/with-vite-react/server/payments.ts new file mode 100644 index 00000000..e2a99184 --- /dev/null +++ b/examples/with-vite-react/server/payments.ts @@ -0,0 +1,101 @@ +// The payment API. The browser sends addresses and an amount; this builds the +// transaction with the Blockfrost key, which never leaves the server. The +// browser only signs, then sends the signed transaction back for submission. +import type { IncomingMessage, ServerResponse } from "node:http" + +import { Address, Assets, Client, Transaction, TransactionHash } from "@evolution-sdk/evolution" + +import { CHAINS, networkIdOf, parseNetwork } from "../src/network.ts" + +export type PaymentEnv = { network: string | undefined; blockfrostProjectId: string | undefined } + +const MAX_BODY_BYTES = 64 * 1024 + +export function createPaymentApi(env: PaymentEnv) { + const network = parseNetwork(env.network) + const blockfrostProjectId = env.blockfrostProjectId + const provider = blockfrostProjectId?.startsWith(network) + ? Client.make(CHAINS[network]).withBlockfrost({ + baseUrl: `https://cardano-${network}.blockfrost.io/api/v0`, + projectId: blockfrostProjectId + }) + : undefined + + function parseAddress(bech32: unknown, field: string) { + const address = typeof bech32 === "string" ? tryParse(bech32) : undefined + if (!address || address.networkId !== networkIdOf(network)) { + throw new BadRequest(`${field} must be a ${network} address.`) + } + return address + } + + async function buildPayment(body: Record) { + parseAddress(body.from, "from") + const to = parseAddress(body.to, "to") + if (typeof body.lovelace !== "string" || !/^[1-9]\d*$/.test(body.lovelace)) { + throw new BadRequest("lovelace must be a positive whole number, as a string.") + } + const built = await provider! + .withAddress(body.from as string) + .newTx() + .payToAddress({ address: to, assets: Assets.fromLovelace(BigInt(body.lovelace)) }) + .build() + return { txCbor: Transaction.toCBORHex(await built.toTransaction()) } + } + + async function submitTx(body: Record) { + if (typeof body.signedTxCbor !== "string") throw new BadRequest("signedTxCbor is required.") + const hash = await provider!.submitTx(Transaction.fromCBORHex(body.signedTxCbor)) + return { txHash: TransactionHash.toHex(hash) } + } + + const routes: Record) => Promise> = { + "/api/build-payment": buildPayment, + "/api/submit-tx": submitTx + } + + // Returns false for requests outside the API, so the caller can serve them. + return async function handle(req: IncomingMessage, res: ServerResponse): Promise { + const route = routes[req.url ?? ""] + if (!route) return false + if (req.method !== "POST") return (send(res, 405, { error: "Method not allowed" }), true) + if (!provider) { + return (send(res, 500, { error: `Set BLOCKFROST_PROJECT_ID to a ${network} project ID.` }), true) + } + try { + send(res, 200, await route(await readJson(req))) + } catch (err) { + const status = err instanceof BadRequest ? 400 : 502 + send(res, status, { error: err instanceof Error ? err.message : "Request failed." }) + } + return true + } +} + +class BadRequest extends Error {} + +function tryParse(bech32: string) { + try { + return Address.fromBech32(bech32) + } catch { + return undefined + } +} + +async function readJson(req: IncomingMessage): Promise> { + let body = "" + for await (const chunk of req) { + body += chunk + if (body.length > MAX_BODY_BYTES) throw new BadRequest("Request body too large.") + } + try { + return JSON.parse(body) as Record + } catch { + throw new BadRequest("Request body must be JSON.") + } +} + +function send(res: ServerResponse, status: number, data: object) { + res.writeHead(status, { "Content-Type": "application/json" }) + res.end(JSON.stringify(data)) +} diff --git a/examples/with-vite-react/src/components/TransactionBuilder.tsx b/examples/with-vite-react/src/components/TransactionBuilder.tsx index 371ce306..0ad2d77c 100644 --- a/examples/with-vite-react/src/components/TransactionBuilder.tsx +++ b/examples/with-vite-react/src/components/TransactionBuilder.tsx @@ -1,7 +1,21 @@ import { useCardano } from "@cardano-foundation/cardano-connect-with-wallet" import { NetworkType } from "@cardano-foundation/cardano-connect-with-wallet-core" +import { Address, Client, Transaction, TransactionWitnessSet } from "@evolution-sdk/evolution" import { useState } from "react" -import { Address, Assets, Client, mainnet, preprod, preview, TransactionHash } from "@evolution-sdk/evolution" + +import { chain, isMainnet, network } from "../config" + +// Calls the payment API in server/payments.ts and returns its JSON. +async function post(path: string, body: object): Promise { + const res = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error ?? `Request failed (${res.status}).`) + return data as T +} export default function TransactionBuilder() { const [txHash, setTxHash] = useState(null) @@ -10,12 +24,8 @@ export default function TransactionBuilder() { const [recipientAddress, setRecipientAddress] = useState("") const [amount, setAmount] = useState("") - // Determine network from environment variable - const networkEnv = import.meta.env.VITE_NETWORK || "preprod" - const network = networkEnv === "mainnet" ? NetworkType.MAINNET : NetworkType.TESTNET - - const { isConnected, enabledWallet } = useCardano({ - limitNetwork: network + const { enabledWallet, isConnected } = useCardano({ + limitNetwork: isMainnet ? NetworkType.MAINNET : NetworkType.TESTNET }) const handleBuildTransaction = async () => { @@ -46,25 +56,12 @@ export default function TransactionBuilder() { throw new Error("Failed to enable wallet") } - // Determine chain and provider - const blockfrostUrls = { - preprod: "https://cardano-preprod.blockfrost.io/api/v0", - preview: "https://cardano-preview.blockfrost.io/api/v0", - mainnet: "https://cardano-mainnet.blockfrost.io/api/v0" - } as const - - const chainPresets = { preprod, preview, mainnet } - const chain = chainPresets[networkEnv as keyof typeof chainPresets] ?? preprod + // The browser client has no provider: it reads the wallet and signs. + const client = Client.make(chain).withCip30(api) - const txClient = Client.make(chain) - .withBlockfrost({ - baseUrl: blockfrostUrls[networkEnv as keyof typeof blockfrostUrls] ?? blockfrostUrls.preprod, - projectId: import.meta.env.VITE_BLOCKFROST_PROJECT_ID || "" - }) - .withCip30(api) - - // Build transaction (convert ADA to lovelace: 1 ADA = 1,000,000 lovelace) - const lovelaceAmount = BigInt(Math.floor(amountLovelace * 1_000_000)) + // Convert ADA to lovelace (1 ADA = 1,000,000 lovelace). Round, don't floor: + // 1.005 * 1e6 is 1004999.999... in floating point. + const lovelaceAmount = BigInt(Math.round(amountLovelace * 1_000_000)) // Parse address - support both Bech32 (addr1...) and hex formats let parsedAddress: Address.Address @@ -78,22 +75,20 @@ export default function TransactionBuilder() { } } - // Create assets - const assetsToSend = Assets.fromLovelace(lovelaceAmount) - - // Build, sign, and submit transaction - const tx = await txClient - .newTx() - .payToAddress({ - address: parsedAddress, - assets: assetsToSend - }) - .build() + // The server builds the transaction with the Blockfrost key. + const { txCbor } = await post<{ txCbor: string }>("/api/build-payment", { + from: Address.toBech32(await client.address()), + to: Address.toBech32(parsedAddress), + lovelace: lovelaceAmount.toString() + }) - const signed = await tx.sign() - const hash = await signed.submit() + // The wallet asks the user to approve and returns its signatures, which + // are added to the transaction before the server submits it. + const witnessSet = await client.signTx(txCbor) + const signedTxCbor = Transaction.addVKeyWitnessesHex(txCbor, TransactionWitnessSet.toCBORHex(witnessSet)) + const { txHash: hash } = await post<{ txHash: string }>("/api/submit-tx", { signedTxCbor }) - setTxHash(TransactionHash.toHex(hash)) + setTxHash(hash) setRecipientAddress("") setAmount("") } catch (err) { @@ -196,7 +191,7 @@ export default function TransactionBuilder() {
{txHash}
+ (mod as typeof Payments).createPaymentApi({ + network: env.VITE_NETWORK, + blockfrostProjectId: env.BLOCKFROST_PROJECT_ID + }) + ) + server.middlewares.use((req, res, next) => { + handler.then((handle) => handle(req, res)).then((handled) => handled || next(), next) + }) + } + } +} + +export default defineConfig(({ mode }) => ({ + plugins: [react(), paymentApi(mode)], optimizeDeps: { exclude: ["@evolution-sdk/evolution"] }, build: { target: "esnext" } -}) +})) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ec80489..27be3d54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -208,6 +208,9 @@ importers: '@tailwindcss/postcss': specifier: ^4.1.7 version: 4.3.3 + '@types/node': + specifier: ^25.7.0 + version: 25.9.4 '@types/react': specifier: ^19.2.14 version: 19.3.0