Client architecture & patterns
Frontend/backend architecture and client wiring
Client architecture & patterns
Complete architecture patterns showing proper separation between frontend signing and backend transaction building. This page documents how to wire wallets and providers into clients for frontend and backend applications.
Read-Only Wallets
Read-only wallets observe addresses and build transactions but cannot sign. They enable transaction construction on backends using the user's address without any signing capability.
What they are: Wallets configured with an address but no private keys—can build, cannot sign.
When to use: Backend transaction building in web applications. Server builds unsigned transactions for frontend to sign.
Why they work: Provides transaction builder with proper UTxO selection and fee calculation for a specific address, without security risk of keys on backend.
How to secure: Backend never sees keys. Frontend sends user address, backend builds transaction as that user, frontend signs with actual wallet.
Configuration
interface ReadOnlyWalletConfig {
: string // Cardano address to observe (required)
?: string // Stake address for rewards (optional)
}Backend Transaction Building
import { , , , , } from "@evolution-sdk/evolution"
// Backend: Create provider client, then attach read-only wallet
const = .()
.({
: "https://cardano-mainnet.blockfrost.io/api/v0",
: ..!
})
// Attach user's address as read-only wallet (expects bech32 string)
const =
.("addr1qz8eg0aknl96hd3v6x3qfmmz5zhtrq5hn8hmq0x4qd6m2qdppx88rnw3eumv9zv2ctjns05c8jhsqwg98qaxcz2qh45qhjv39c")
// Build unsigned transaction
const = .()
.({
: .(
"addr1qz8eg0aknl96hd3v6x3qfmmz5zhtrq5hn8hmq0x4qd6m2qdppx88rnw3eumv9zv2ctjns05c8jhsqwg98qaxcz2qh45qhjv39c"
),
: .(5_000_000n)
})
// Build returns result, get transaction and serialize
const = await .()
const = await .()
const = .()
// Return to frontend for signing
// Frontend: wallet.signTx(txCbor) → backend.submitTx(signedTxCbor)Frontend: Signing Only
Frontend applications connect to user wallets through CIP-30 but never have provider access. They retrieve addresses, sign transactions built by backends, and hand the signed transaction back to a provider-backed service for submission.
Architecture: API wallet client (no provider) → can sign, cannot build.
Workflow:
- User connects browser extension wallet
- Frontend gets user's address
- Send address to backend
- Backend builds unsigned transaction
- Frontend receives unsigned CBOR
- Frontend requests signature from wallet
- User approves in wallet interface
- Frontend returns signed transaction for provider-backed submission
Implementation
import { Address, Transaction, TransactionWitnessSet, mainnet, Client } from "@evolution-sdk/evolution"
// 1. Connect wallet
declare const cardano: any
const walletApi = await cardano.eternl.enable()
// 2. Create signing-only client
const client = Client.make(mainnet)
.withCip30(walletApi)
// 3. Get user address for backend
const address = Address.toBech32(await client.address())
// 4. Send address to backend, receive unsigned tx CBOR
const unsignedTxCbor = await fetch("/api/build-tx", {
method: "POST",
body: JSON.stringify({ userAddress: address })
})
.then((r) => r.json())
.then((data) => data.txCbor)
// 5. Sign with user wallet
const witnessSet = await client.signTx(unsignedTxCbor)
// 6. Merge wallet witnesses into the unsigned transaction
const signedTxCbor = Transaction.addVKeyWitnessesHex(
unsignedTxCbor,
TransactionWitnessSet.toCBORHex(witnessSet)
)
// 7. Return signed transaction to backend for submission
const { txHash } = await fetch("/api/submit-tx", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ signedTxCbor })
}).then((r) => r.json()) as { txHash: string }
console.log("Transaction submitted:", txHash)Security: Provider API keys never exposed to frontend. User approves every signature. Keys stay on the user's device. Cannot build transactions without provider access.
Backend: Building Only
Backend services use read-only wallets configured with user addresses to build unsigned transactions. They have provider access for blockchain queries but zero signing capability.
Architecture: Read-only wallet + provider → can build, cannot sign.
Workflow:
- Receive user address from frontend
- Create read-only wallet with that address
- Build transaction with proper UTxO selection
- Calculate fees, validate outputs
- Return unsigned transaction CBOR
- Frontend handles signing and submission
Implementation
import { Address, Assets, Transaction, mainnet, Client } from "@evolution-sdk/evolution"
// Backend endpoint
export async function buildTransaction(userAddress: string) {
// Create read-only client with user's address
const client = Client.make(mainnet)
.withBlockfrost({
baseUrl: "https://cardano-mainnet.blockfrost.io/api/v0",
projectId: process.env.BLOCKFROST_PROJECT_ID!
})
.withAddress(userAddress)
// Build unsigned transaction
const builder = client.newTx()
builder.payToAddress({
address: Address.fromBech32(
"addr1qz8eg0aknl96hd3v6x3qfmmz5zhtrq5hn8hmq0x4qd6m2qdppx88rnw3eumv9zv2ctjns05c8jhsqwg98qaxcz2qh45qhjv39c"
),
assets: Assets.fromLovelace(5_000_000n)
})
// Returns unsigned transaction
const unsignedTx = await builder.build()
const txCbor = unsignedTx.toCBOR()
return { txCbor }
}Security: Backend never sees private keys. Cannot sign even if server is compromised. Provider keys protected on server. Cannot submit transactions without signing from frontend.
Full Flow: Build → Sign → Submit
Complete architecture showing frontend/backend separation with proper security model.
The frontend owns address retrieval and witness creation. The provider-backed backend owns transaction construction and final submission.
Components:
- Frontend: API wallet (CIP-30) for address retrieval and signing
- Backend: Provider-backed client for building and submission
- Security: Keys stay on user device, provider keys stay on server
Complete Example
// @filename: shared.ts
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
const = await .eternl.enable()
const = .()
.()
// 2. Get user address (returns Core Address, convert to bech32 for backend)
const = .(await .())
// 3. Request backend to build transaction
const = await ("/api/build-payment", {
: "POST",
: { "Content-Type": "application/json" },
: .({
,
,
: .()
})
})
const { } = (await .()) as
// 4. Sign with user wallet (prompts user approval)
const = await .()
// 5. Merge wallet witnesses into the unsigned transaction
const = .(
,
.()
)
// 6. Send signed transaction back 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 bech32 addresses from frontend to Core Address
const = .()
// Attach user's address as read-only wallet
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
Why This Works
User keys never leave their device. Provider API keys never exposed to frontend. Backend cannot sign transactions (compromised server results in no fund loss). Frontend cannot build transactions alone (no provider access). Both components required for complete transactions. Clean separation of concerns provides scalable architecture.
Method Matrix
These rows reflect the assembled client shapes used on this page.
| Method | Seed + Provider | Private Key + Provider | API Wallet | Read-Only + Provider |
|---|---|---|---|---|
address() | Yes | Yes | Yes | Yes |
rewardAddress() | Yes | Yes | Yes | Yes |
newTx() | Yes | Yes | No | Yes |
signMessage() | Yes | Yes | Yes | No |
signTx() | Yes | Yes | Yes | No |
submitTx() | Yes | Yes | No | Yes |
Common Mistakes
Error: Frontend trying to build:
// WRONG - Frontend has no provider
const client = Client.make(mainnet)
.withCip30(walletApi)
const builder = client.newTx() // Error: Cannot build without providerCorrect - Frontend signs only:
// CORRECT - Frontend signs, backend builds
const client = Client.make(mainnet)
.withCip30(walletApi)
// Get tx from backend, then sign
const witnessSet = await client.signTx(txCborFromBackend)Error: Backend trying to sign:
// WRONG - Backend has no private keys
const client = Client.make(mainnet)
.withBlockfrost({ ...providerConfig })
.withAddress(userAddress)
await client.signTx(txCbor) // Error: Cannot sign with read-only walletCorrect - Backend builds only:
// CORRECT - Backend builds, frontend signs
const client = Client.make(mainnet)
.withBlockfrost({ ...providerConfig })
.withAddress(userAddress)
const builder = client.newTx()
const unsignedTx = await builder.build() // Returns unsigned transactionNext Steps
- API Wallets - CIP-30 integration details
- Security - Complete security guide
- Private Key - Backend automation