Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions examples/with-vite-react/.env.example
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion examples/with-vite-react/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ lerna-debug.log*

node_modules
dist
dist-ssr
dist-server
*.local

# Environment variables
Expand Down
70 changes: 31 additions & 39 deletions examples/with-vite-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -101,62 +105,50 @@ 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(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

### Building for Production

```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

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
Expand All @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions examples/with-vite-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -18,9 +18,10 @@
"react-dom": "^19.2.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.7",
"@types/node": "^25.7.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@tailwindcss/postcss": "^4.1.7",
"@vitejs/plugin-react": "^6.0.1",
"tailwindcss": "^4.1.7",
"typescript": "^6.0.3",
Expand Down
55 changes: 55 additions & 0 deletions examples/with-vite-react/server/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Production server: serves the built app from dist/ and the payment API.
// Build with `npm run build`, then run with `npm 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<string, string> = {
".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 `npm run build` first.")
return
}
res.writeHead(200, { "Content-Type": asset.type }).end(asset.body)
}).listen(port, () => console.log(`http://localhost:${port}`))
101 changes: 101 additions & 0 deletions examples/with-vite-react/server/payments.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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<string, unknown>) {
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<string, (body: Record<string, unknown>) => Promise<object>> = {
"/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<boolean> {
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<Record<string, unknown>> {
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<string, unknown>
} 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))
}
Loading
Loading