Evolution SDK
ClientsArchitecture

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:

  1. API Wallet Client (frontend): API wallet only, no provider → can sign, cannot build
  2. 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

FeatureAPI Wallet Client (Frontend)Read-Only Client (Backend)
ComponentsAPI wallet onlyRead-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 CaseUser signingTransaction 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 provider

Fix: 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 wallet

Fix: 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:

MethodFull ClientAPI Wallet ClientRead-Only ClientProvider-Only Client
address()
rewardAddress()
newTx()
signMessage()
signTx()
submitTx()
getUtxos()
query*()

Next Steps

On this page