Frontend/Backend Architecture
Separating signing and building across client types
Frontend/Backend Architecture
Complete architecture patterns showing proper separation between frontend signing and backend transaction building using different client types.
Overview
Modern web applications require separation of concerns:
- Frontend: User wallets for signing (API wallet client)
- Backend: Transaction building with provider access (Read-only client)
- Security: Keys on user device, provider keys on server
This pattern uses two different client types:
- API Wallet Client (frontend): API wallet only, no provider → can sign, cannot build
- Read-Only Client (backend): Read-only wallet + provider → can build, cannot sign
Architecture Pattern
Frontend: API Wallet Client
Frontend applications use API wallet clients (CIP-30) for signing only. They have no provider access and cannot build transactions.
Client Type: API Wallet Client Components: API wallet (no provider) Capabilities: Address retrieval and transaction signing Cannot Do: Build transactions, query blockchain, fee calculation, provider-backed submission
import { , , , , } from "@evolution-sdk/evolution"
declare const : any
// 1. Connect to user's browser wallet
const = await .eternl.enable()
// 2. Create API wallet client (no provider)
const = .()
.()
// 3. Get user address to send to backend
const = .(await .())
// 4. Receive unsigned transaction from backend
const = await ("/api/build-tx", {
: "POST",
: { "Content-Type": "application/json" },
: .({ : })
})
.(() => .())
.(() => .txCbor)
// 5. Sign with user wallet (prompts approval)
const = await .()
// 6. Merge witnesses into the unsigned transaction
const = .(
,
.()
)
// 7. Return signed transaction to backend for provider submission
const { } = await ("/api/submit-tx", {
: "POST",
: { "Content-Type": "application/json" },
: .({ })
}).(() => .()) as { : string }
.("Transaction submitted:", )Security:
- Provider API keys never exposed to frontend
- User approves every signature in wallet interface
- Private keys stay on user's device
- Cannot build transactions without provider access
Backend: Read-Only Client
Backend services use read-only clients configured with user addresses to build unsigned transactions. They have provider access but zero signing capability.
Client Type: Read-Only Client Components: Read-only wallet + provider Capabilities: Address observation, transaction building, UTxO selection, fee calculation Cannot Do: Sign transactions, access private keys
import { , , , , } from "@evolution-sdk/evolution"
export async function (: string) {
// Create read-only client with user's address (bech32 string)
const = .()
.({
: "https://cardano-mainnet.blockfrost.io/api/v0",
: ..!
})
.()
// Build unsigned transaction
const = .()
.({
: .(
"addr1qz8eg0aknl96hd3v6x3qfmmz5zhtrq5hn8hmq0x4qd6m2qdppx88rnw3eumv9zv2ctjns05c8jhsqwg98qaxcz2qh45qhjv39c"
),
: .(5000000n)
})
// Build and return unsigned transaction
const = await .()
const = await .()
const = .()
return { }
}Security:
- Backend never sees or has access to private keys
- Cannot sign even if server is compromised
- Provider API keys protected on server side
- Cannot submit transactions without signatures from frontend
Complete Flow: Build → Sign → Submit
Full architecture showing frontend/backend separation with proper security model.
The browser signs and produces witnesses. The backend, which has provider access, assembles the signed transaction for broadcast and submits it.
End-to-End Example
// @filename: shared.ts
export type = {
: string
: string
: string
}
export type = {
: string
}
export type = {
: string
}
// @filename: frontend.ts
// ===== Frontend (Browser) =====
import { , , , , } from "@evolution-sdk/evolution"
import type { , , } from "./shared"
declare const : any
async function (: string, : bigint) {
// 1. Connect user wallet (CIP-30)
const = await .eternl.enable()
// 2. Create API wallet client (signing only)
const = .()
.()
// 3. Get user address (returns Core Address, convert to bech32 for backend)
const = .(await .())
// 4. Request backend to build transaction
const : = {
,
,
: .()
}
const = await ("/api/build-payment", {
: "POST",
: { "Content-Type": "application/json" },
: .()
})
const { } = (await .()) as
// 5. Sign with user wallet (prompts user approval)
const = await .()
// 6. Merge witnesses into the unsigned transaction
const = .(
,
.()
)
// 7. Return signed transaction to backend for submission
const = await ("/api/submit-tx", {
: "POST",
: { "Content-Type": "application/json" },
: .({ })
})
const { } = (await .()) as
return
}
// @filename: backend.ts
// ===== Backend (Server) =====
import { , , , , , } from "@evolution-sdk/evolution"
import type { , } from "./shared"
const = .()
.({
: "https://cardano-mainnet.blockfrost.io/api/v0",
: ..!
})
export async function (
: string,
: string,
: bigint
): <> {
// Convert recipient to Core Address for payToAddress
const = .()
// Create read-only client with user's address (bech32 string)
const = .()
// Build unsigned transaction
const = .()
.({
: ,
: .()
})
// Return unsigned CBOR for frontend to sign
const = await .()
const = await .()
const = .()
return { }
}
export async function (: string): <> {
const = .()
const = await .()
return { : .() }
}Why This Works
This architecture provides complete security through proper separation:
User Security:
- Private keys never leave user's device
- User approves every transaction in their wallet
- Frontend cannot build transactions alone
- Backend cannot sign transactions alone
Application Security:
- Provider API keys never exposed to frontend
- Compromised server results in no fund loss (no keys)
- Frontend cannot query blockchain without backend
- Both components required for complete transactions
Scalability:
- Clean separation of concerns
- Backend handles complex blockchain queries
- Frontend handles simple signing UX
- Easy to test and maintain independently
Client Type Comparison
| Feature | API Wallet Client (Frontend) | Read-Only Client (Backend) |
|---|---|---|
| Components | API wallet only | Read-only wallet + provider |
| Can Sign | ✅ Yes | ❌ No |
| Can Build | ❌ No | ✅ Yes |
| Can Query | ❌ No | ✅ Yes |
| Has Keys | ✅ Yes (on device) | ❌ No |
| Provider Access | ❌ No | ✅ Yes |
| Use Case | User signing | Transaction building |
Common Mistakes
❌ Frontend Trying to Build
// WRONG - API wallet client has no provider
const client = Client.make(mainnet)
.withCip30(walletApi)
const builder = client.newTx() // Error: Cannot build without providerFix: Get unsigned transaction from backend instead.
✅ Correct Frontend Pattern
// CORRECT - API wallet client signs only
const client = Client.make(mainnet)
.withCip30(walletApi)
// Get transaction from backend
const { txCbor } = await fetch("/api/build-tx").then((r) => r.json())
// Sign with user approval
const witnessSet = await client.signTx(txCbor)❌ Backend Trying to Sign
// WRONG - Read-only client has no private keys
const client = Client.make(mainnet)
.withBlockfrost({ ...providerConfig })
.withAddress(userAddress)
await client.signTx(txCbor) // Error: Cannot sign with read-only walletFix: Return unsigned transaction to frontend for signing.
✅ Correct Backend Pattern
// CORRECT - Read-only client builds only (needs provider + address)
const client = Client.make(mainnet)
.withBlockfrost({ ...providerConfig })
.withAddress(userAddress)
// Build unsigned transaction
const builder = client.newTx()
// ... configure transaction ...
const result = await builder.build()
const unsignedTx = await result.toTransaction()
// Return to frontend for signing
return { txCbor: Transaction.toCBORHex(unsignedTx) }Method Availability by Client Type
Understanding what each client type can do:
| Method | Full Client | API Wallet Client | Read-Only Client | Provider-Only Client |
|---|---|---|---|---|
address() | ✅ | ✅ | ✅ | ❌ |
rewardAddress() | ✅ | ✅ | ✅ | ❌ |
newTx() | ✅ | ❌ | ✅ | ✅ |
signMessage() | ✅ | ✅ | ❌ | ❌ |
signTx() | ✅ | ✅ | ❌ | ❌ |
submitTx() | ✅ | ❌ | ✅ | ✅ |
getUtxos() | ✅ | ❌ | ✅ | ✅ |
query*() | ✅ | ❌ | ✅ | ✅ |