Build a Wallet App
Build a Cardano wallet application integrated with Hydra Layer 2, from wallet creation through transaction building and Head submission. Start by cloning a ready-made React or Vue example, then graduate to the full service-layer reference implementation when you want to understand every moving part.
Architecture
An end-to-end wallet app is layered on four SDK packages:
- UI — React, Vue, or Svelte
- Wallet —
@hydra-sdk/core(AppWallet, accounts, keys) - Transaction —
@hydra-sdk/transaction(TxBuilder) - Hydra L2 —
@hydra-sdk/bridge(HydraBridge) - WASM —
@hydra-sdk/cardano-wasm
A production app organizes this into a service layer — one service per concern — with a store binding it to the UI:
hydra-wallet-app/
├── src/
│ ├── services/
│ │ ├── WalletService.ts
│ │ ├── HydraService.ts
│ │ └── TransactionService.ts
│ ├── components/
│ │ ├── WalletManager.vue
│ │ ├── HydraConnection.vue
│ │ └── TransactionBuilder.vue
│ ├── stores/
│ │ └── AppStore.ts
│ └── App.vue
├── package.json
└── vite.config.ts
The core flow is the same regardless of framework:
- Initialize a wallet with
AppWallet - Get the
account(address, keys) - Build transactions with
TxBuilder - Optional Hydra — use
HydraBridgeto fetchprotocol paramsand submit a tx to a Head - Handle events and clean up (disconnect)
@hydra-sdk/bridge entirely and submit transactions to Cardano through your own provider.Clone and run
The fastest path is to clone a ready-made example from the Hydra SDK Examples repository and run it. Pick your framework:
React + Vite starter. Uses vite.config.js with the react() plugin plus the WASM and polyfill plugins described in .
git clone https://github.com/Vtechcom/hydra-sdk-examples.git
cd hydra-sdk-examples/react-app-with-vite
Vue 3 + TypeScript + Vite starter. Uses vite.config.ts with the vue() plugin, vite-plugin-vue-devtools, an @ path alias, and the WASM and polyfill plugins described in .
git clone https://github.com/Vtechcom/hydra-sdk-examples.git
cd hydra-sdk-examples/vue-app
Both examples share the same install and run commands:
pnpm install
# or
npm install
pnpm dev
# or
npm run dev
Then open http://localhost:5173 in your browser. The demo generates a new mnemonic, creates an AppWallet on the PREPROD testnet, and displays the derived Bech32 address.
Network configuration
Both examples default to the PREPROD testnet for development and testing:
networkId: NETWORK_ID.PREPROD
For mainnet, change to:
networkId: NETWORK_ID.MAINNET
Troubleshooting
Most issues come from the browser build not being configured for WASM. Check these first:
- WASM loading errors — ensure
vite-plugin-wasmis installed and configured, and that your browser supports WebAssembly. Buffer/processnot defined — verify thevite-plugin-node-polyfillsconfiguration and that globals are set correctly.- Module resolution errors — check that all Hydra SDK packages are installed and the Vite config is correct (Vue also needs its path aliases configured).
- TypeScript compilation errors — run
pnpm type-check, ensure all Hydra SDK types are imported, and verify yourtsconfig. - Build errors — ensure
@hydra-sdk/cardano-wasmis excluded from optimization and all required plugins are indevDependencies.
Build it yourself
To go beyond the starter, assemble the service layer yourself. This is the full reference implementation from the complete Hydra wallet app: a WalletService, HydraService, and TransactionService, a Pinia store, and the root Vue component. It applies a service-layer pattern (business logic separated from UI), an event-driven HydraService, and centralized reactive state.
Install the packages
Add the SDK packages and the Vite plugins needed for the browser WASM build:
pnpm add @hydra-sdk/core @hydra-sdk/transaction @hydra-sdk/bridge @hydra-sdk/cardano-wasm
pnpm add vite-plugin-wasm vite-plugin-top-level-await vite-plugin-node-polyfills -D
The full package.json for the reference app:
{
"name": "hydra-wallet-app",
"version": "1.0.0",
"description": "Cardano wallet application with Hydra SDK integration",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@hydra-sdk/core": "^1.0.0",
"@hydra-sdk/bridge": "^1.0.0",
"@hydra-sdk/transaction": "^1.0.0",
"@hydra-sdk/cardano-wasm": "^1.0.0",
"vue": "^3.3.0",
"pinia": "^2.1.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.4.0",
"typescript": "^5.2.0",
"vue-tsc": "^1.8.0",
"vite": "^4.4.0",
"vite-plugin-wasm": "^3.2.2",
"vite-plugin-top-level-await": "^1.3.1",
"vite-plugin-node-polyfills": "^0.15.0",
"tailwindcss": "^3.3.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0"
}
}
Configure Vite
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import wasm from 'vite-plugin-wasm'
import topLevelAwait from 'vite-plugin-top-level-await'
import { nodePolyfills } from 'vite-plugin-node-polyfills'
export default defineConfig({
plugins: [
vue(),
wasm(),
topLevelAwait(),
nodePolyfills({
include: ['buffer'],
globals: {
Buffer: true,
global: false,
process: false
}
})
],
optimizeDeps: {
exclude: ['@hydra-sdk/cardano-wasm']
}
})
vite-plugin-wasm loads the SDK's WebAssembly crypto, vite-plugin-top-level-await enables module-level await, and vite-plugin-node-polyfills supplies Buffer in the browser. Excluding @hydra-sdk/cardano-wasm from optimizeDeps keeps the WASM package out of dependency pre-bundling.Create the wallet service
Wraps AppWallet for create/restore, account derivation, and balance queries.
// src/services/WalletService.ts
import { AppWallet, NETWORK_ID, UTxO } from '@hydra-sdk/core'
export class WalletService {
private wallet: AppWallet | null = null
private mnemonic: string[] = []
private isInitialized = false
async createWallet(mnemonic?: string): Promise<AppWallet> {
const words = mnemonic ? mnemonic.split(' ') : AppWallet.brew()
this.mnemonic = words
this.wallet = new AppWallet({
networkId: NETWORK_ID.PREPROD,
key: {
type: 'mnemonic',
words: words
}
})
this.isInitialized = true
return this.wallet
}
async restoreWallet(mnemonic: string): Promise<AppWallet> {
return this.createWallet(mnemonic)
}
getWallet(): AppWallet {
if (!this.wallet || !this.isInitialized) {
throw new Error('Wallet not initialized')
}
return this.wallet
}
getAccount(accountIndex = 0, addressIndex = 0) {
const wallet = this.getWallet()
return wallet.getAccount(accountIndex, addressIndex)
}
async getBalance(address?: string): Promise<{ lovelace: string; assets: any[] }> {
const wallet = this.getWallet()
const account = this.getAccount()
const targetAddress = address || account.baseAddressBech32
try {
// Assume there's an API to query UTxOs
const utxos = await this.queryUTxOs(targetAddress)
let lovelaceTotal = 0
const assetMap = new Map<string, number>()
utxos.forEach(utxo => {
utxo.output.amount.forEach(asset => {
if (asset.unit === 'lovelace') {
lovelaceTotal += parseInt(asset.quantity)
} else {
const current = assetMap.get(asset.unit) || 0
assetMap.set(asset.unit, current + parseInt(asset.quantity))
}
})
})
const assets = Array.from(assetMap.entries()).map(
([unit, quantity]) => ({ unit, quantity: quantity.toString() })
)
return {
lovelace: lovelaceTotal.toString(),
assets
}
} catch (error) {
console.error('Error fetching balance:', error)
return { lovelace: '0', assets: [] }
}
}
async queryUTxOs(address: string): Promise<UTxO[]> {
// Integration with Blockfrost, Hexcore, or other services
const response = await fetch(`/api/addresses/${address}/utxos`)
return response.json()
}
getMnemonic(): string {
this.getWallet()
return this.mnemonic.join(' ')
}
getAddresses() {
const account = this.getAccount()
return {
base: account.baseAddressBech32,
stake: account.rewardAddressBech32,
enterprise: account.enterpriseAddressBech32
}
}
isWalletInitialized(): boolean {
return this.isInitialized && this.wallet !== null
}
}
export const walletService = new WalletService()
queryUTxOs here is a placeholder that hits /api/addresses/:address/utxos. Swap in your own provider — Blockfrost, Hexcore, or a Cardano node. See the Utilities Cookbook for helpers.Create the Hydra service
Extends EventEmitter to expose the Head lifecycle as high-level events. It supports both a plain WebSocket url and an authenticated HexcoreConnector.
// src/services/HydraService.ts
import { HydraBridge, HexcoreConnector } from '@hydra-sdk/bridge'
import { EventEmitter } from 'events'
export interface HydraConfig {
url?: string
token?: string
useHexcore?: boolean
verbose?: boolean
}
export class HydraService extends EventEmitter {
private bridge: HydraBridge | null = null
private isConnected = false
private headStatus = 'idle'
private config: HydraConfig
constructor(config: HydraConfig = {}) {
super()
this.config = {
url: 'ws://localhost:4001',
useHexcore: false,
verbose: true,
...config
}
}
async connect(): Promise<void> {
try {
if (this.config.useHexcore && this.config.token) {
const connector = new HexcoreConnector(this.config.url!, {
socketIoOptions: {
auth: { token: this.config.token },
transports: ['websocket'],
timeout: 20000
},
namespace: 'hydra'
})
this.bridge = new HydraBridge({
connector: connector,
verbose: this.config.verbose
})
} else {
this.bridge = new HydraBridge({
url: this.config.url!,
verbose: this.config.verbose
})
}
this.setupEventListeners()
await this.bridge.connect()
} catch (error) {
console.error('Hydra connection error:', error)
this.emit('error', error)
throw error
}
}
private setupEventListeners(): void {
if (!this.bridge) return
this.bridge.events.on('onConnected', () => {
this.isConnected = true
this.emit('connected')
})
this.bridge.events.on('onDisconnected', () => {
this.isConnected = false
this.headStatus = 'disconnected'
this.emit('disconnected')
})
this.bridge.events.on('onConnectError', (error) => {
console.error('Hydra Bridge error:', error)
this.emit('error', error)
})
this.bridge.events.on('onMessage', (payload) => {
this.handleHydraMessage(payload)
})
}
private handleHydraMessage(payload: any): void {
switch (payload.tag) {
case 'Greetings':
this.headStatus = payload.headStatus?.toLowerCase() || 'idle'
this.emit('status', this.headStatus)
break
case 'HeadIsOpen':
this.headStatus = 'open'
this.emit('status', this.headStatus)
this.emit('headOpen', { parties: payload.parties })
break
case 'SnapshotConfirmed':
this.emit('snapshot', {
number: payload.snapshot.number,
utxoCount: Object.keys(payload.snapshot.utxo || {}).length
})
break
case 'HeadIsClosed':
this.headStatus = 'closed'
this.emit('status', this.headStatus)
this.emit('headClosed')
break
case 'HeadIsFinalized':
this.headStatus = 'finalized'
this.emit('status', this.headStatus)
this.emit('headFinalized')
break
case 'TxValid':
this.emit('transactionConfirmed', {
txId: payload.transaction?.txId,
transaction: payload.transaction
})
break
case 'TxInvalid':
this.emit('transactionInvalid', {
transaction: payload.transaction,
error: payload.validationError
})
break
default:
this.emit('message', payload)
}
}
async initHead(): Promise<void> {
if (!this.bridge || !this.isConnected) {
throw new Error('Not connected to Hydra Bridge')
}
if (this.headStatus !== 'idle') {
throw new Error(`Cannot init head in state: ${this.headStatus}`)
}
this.bridge.commands.init()
}
async closeHead(): Promise<void> {
if (!this.bridge || !this.isConnected) {
throw new Error('Not connected to Hydra Bridge')
}
if (this.headStatus !== 'open') {
throw new Error(`Cannot close head in state: ${this.headStatus}`)
}
this.bridge.commands.close()
}
async submitTransaction(txCbor: string, txId: string): Promise<any> {
if (!this.bridge || !this.isConnected) {
throw new Error('Not connected to Hydra Bridge')
}
if (this.headStatus !== 'open') {
throw new Error('Head not open for transaction processing')
}
return this.bridge.submitTxSync({
type: 'Witnessed Tx ConwayEra',
description: 'Ledger Cddl Format',
cborHex: txCbor,
txId
}, { timeout: 30000 })
}
async queryUTxO(): Promise<any> {
if (!this.bridge || !this.isConnected) {
throw new Error('Not connected to Hydra Bridge')
}
return this.bridge.querySnapshotUtxo()
}
async disconnect(): Promise<void> {
if (this.bridge) {
await this.bridge.disconnect()
this.bridge = null
}
this.isConnected = false
this.headStatus = 'disconnected'
}
getStatus(): { connected: boolean; headStatus: string } {
return {
connected: this.isConnected,
headStatus: this.headStatus
}
}
}
export const hydraService = new HydraService()
For deeper Hydra patterns — Hexcore authentication, the balance cache, and the submitTx callback API — see the Hydra Integration guide.
Create the transaction service
Builds, signs, and submits transactions on both L1 and inside a Hydra Head, and shows a Plutus script spend.
// src/services/TransactionService.ts
import { TxBuilder } from '@hydra-sdk/transaction'
import { Deserializer, UTxO } from '@hydra-sdk/core'
import { walletService } from './WalletService'
import { hydraService } from './HydraService'
export interface TransactionRequest {
recipient: string
amount: string
assets?: Array<{ unit: string; quantity: string }>
metadata?: any
isHydra?: boolean
}
export class TransactionService {
async sendTransaction(request: TransactionRequest): Promise<string> {
const wallet = walletService.getWallet()
const account = walletService.getAccount()
try {
// Get UTxOs
const utxos = request.isHydra
? await hydraService.queryUTxO()
: await walletService.queryUTxOs(account.baseAddressBech32)
// Prepare assets to send
const outputs = [
{ unit: 'lovelace', quantity: request.amount },
...(request.assets || [])
]
// Create TxBuilder
const txBuilder = new TxBuilder({
isHydra: request.isHydra,
params: request.isHydra ? { minFeeA: 0, minFeeB: 0 } : undefined
})
// Build transaction
let builder = txBuilder
.setInputs(utxos)
.txOut(request.recipient, outputs)
.changeAddress(account.baseAddressBech32)
// Add metadata if present
if (request.metadata) {
builder = builder.metadataValue(674, request.metadata)
}
// Set fee (0 for Hydra)
if (request.isHydra) {
builder = builder.setFee('0')
}
// Complete transaction
const tx = await builder.complete()
const txCbor = tx.to_hex()
// Sign transaction
const signedTx = await wallet.signTx(txCbor, false, 0, 0)
const txHash = Deserializer.deserializeTx(signedTx).transaction_hash().to_hex()
// Submit transaction
if (request.isHydra) {
await hydraService.submitTransaction(signedTx, txHash)
} else {
// Submit to L1 via API
await this.submitToL1(signedTx)
}
return txHash
} catch (error) {
console.error('Error sending transaction:', error)
throw error
}
}
private async submitToL1(txCbor: string): Promise<void> {
// Integration with Blockfrost, Hexcore or other services
const response = await fetch('/api/transactions/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ txCbor })
})
if (!response.ok) {
throw new Error('Transaction submission failed')
}
}
async buildContractTransaction(
contractAddress: string,
scriptCbor: string,
redeemer: any,
lockAmount: string
): Promise<string> {
const wallet = walletService.getWallet()
const account = walletService.getAccount()
const utxos = await walletService.queryUTxOs(account.baseAddressBech32)
// Find collateral
const collateralUTxO = utxos.find(utxo =>
utxo.output.amount.find(asset =>
asset.unit === 'lovelace' && Number(asset.quantity) >= 5_000_000
)
)
if (!collateralUTxO) {
throw new Error('No suitable collateral UTxO found')
}
const txBuilder = new TxBuilder()
const tx = await txBuilder
.setInputs(utxos.filter(u => u !== collateralUTxO))
.txOut(contractAddress, [{ unit: 'lovelace', quantity: lockAmount }])
.txInCollateral(
collateralUTxO.input.txHash,
collateralUTxO.input.outputIndex,
collateralUTxO.output.amount,
collateralUTxO.output.address
)
.txInScript(scriptCbor)
.txInRedeemerValue(redeemer)
.changeAddress(account.baseAddressBech32)
.complete()
const signedTx = await wallet.signTx(tx.to_hex())
await this.submitToL1(signedTx)
return Deserializer.deserializeTx(signedTx).transaction_hash().to_hex()
}
}
export const transactionService = new TransactionService()
isHydra is true, construct TxBuilder with params: { minFeeA: 0, minFeeB: 0 } and call setFee('0') — otherwise the Head will reject the transaction. See Transactions for the L1 fee path, Mint & Burn Tokens for minting, and Smart Contracts in Hydra for script spends.Add the Pinia store
Binds the three services to reactive state, wires up the HydraService event listeners, and exposes actions to the UI.
// src/stores/AppStore.ts
import { defineStore } from 'pinia'
import { walletService } from '../services/WalletService'
import { hydraService } from '../services/HydraService'
import { transactionService } from '../services/TransactionService'
export const useAppStore = defineStore('app', {
state: () => ({
// Wallet state
isWalletInitialized: false,
walletAddresses: null as any,
balance: { lovelace: '0', assets: [] } as any,
// Hydra state
isHydraConnected: false,
headStatus: 'idle',
hydraMessages: [] as any[],
// Transaction state
pendingTransactions: [] as string[],
transactionHistory: [] as any[],
// UI state
loading: false,
error: null as string | null
}),
getters: {
formattedBalance: (state) => {
const ada = (parseInt(state.balance.lovelace) / 1_000_000).toFixed(6)
return `${ada} ADA`
},
canInitHead: (state) => {
return state.isHydraConnected && state.headStatus === 'idle'
},
canSendHydraTransaction: (state) => {
return state.isHydraConnected && state.headStatus === 'open'
}
},
actions: {
// Wallet actions
async createWallet(mnemonic?: string) {
this.loading = true
this.error = null
try {
await walletService.createWallet(mnemonic)
this.isWalletInitialized = true
this.walletAddresses = walletService.getAddresses()
await this.updateBalance()
} catch (error: any) {
this.error = error.message
throw error
} finally {
this.loading = false
}
},
async updateBalance() {
if (!this.isWalletInitialized) return
try {
this.balance = await walletService.getBalance()
} catch (error: any) {
console.error('Error updating balance:', error)
}
},
// Hydra actions
async connectHydra(config?: any) {
this.loading = true
this.error = null
try {
if (config) {
Object.assign(hydraService.config, config)
}
// Setup event listeners
hydraService.on('connected', () => {
this.isHydraConnected = true
})
hydraService.on('disconnected', () => {
this.isHydraConnected = false
this.headStatus = 'disconnected'
})
hydraService.on('status', (status) => {
this.headStatus = status
})
hydraService.on('message', (message) => {
this.hydraMessages.unshift({
...message,
timestamp: new Date().toISOString()
})
// Keep only the last 50 messages
if (this.hydraMessages.length > 50) {
this.hydraMessages = this.hydraMessages.slice(0, 50)
}
})
hydraService.on('error', (error) => {
this.error = error.message
})
await hydraService.connect()
} catch (error: any) {
this.error = error.message
throw error
} finally {
this.loading = false
}
},
async disconnectHydra() {
await hydraService.disconnect()
hydraService.removeAllListeners()
},
async initHead() {
try {
await hydraService.initHead()
} catch (error: any) {
this.error = error.message
throw error
}
},
async closeHead() {
try {
await hydraService.closeHead()
} catch (error: any) {
this.error = error.message
throw error
}
},
// Transaction actions
async sendTransaction(request: any) {
this.loading = true
this.error = null
try {
const txHash = await transactionService.sendTransaction(request)
this.pendingTransactions.push(txHash)
// Update balance after sending
setTimeout(() => this.updateBalance(), 2000)
return txHash
} catch (error: any) {
this.error = error.message
throw error
} finally {
this.loading = false
}
},
// Utility actions
clearError() {
this.error = null
},
clearMessages() {
this.hydraMessages = []
}
}
})
Wire up the App component
The root component reads store state, renders wallet/Hydra/balance status, and cleans up the auto-refresh interval and Hydra connection on unmount.
<!-- src/App.vue -->
<script lang="ts" setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useAppStore } from './stores/AppStore'
import WalletManager from './components/WalletManager.vue'
import HydraConnection from './components/HydraConnection.vue'
import TransactionBuilder from './components/TransactionBuilder.vue'
const store = useAppStore()
const activeTab = ref('wallet')
// Auto-refresh balance every 30 seconds
let balanceInterval: NodeJS.Timeout | null = null
onMounted(() => {
balanceInterval = setInterval(() => {
if (store.isWalletInitialized) {
store.updateBalance()
}
}, 30000)
})
onBeforeUnmount(() => {
if (balanceInterval) {
clearInterval(balanceInterval)
}
store.disconnectHydra()
})
</script>
<template>
<div class="min-h-screen bg-gray-50">
<header class="bg-white shadow-sm border-b">
<div class="max-w-6xl mx-auto px-4 py-6">
<h1 class="text-3xl font-bold text-gray-900">
Hydra Wallet App
</h1>
<p class="mt-2 text-gray-600">
Cardano wallet application with Hydra Layer 2 integration
</p>
</div>
</header>
<main class="max-w-6xl mx-auto px-4 py-8">
<!-- Error Display -->
<div v-if="store.error" class="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="text-red-600 mr-3">⚠️</div>
<p class="text-red-800">{{ store.error }}</p>
</div>
<button @click="store.clearError()" class="text-red-600 hover:text-red-800">
✕
</button>
</div>
</div>
<!-- Status Bar -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<div class="bg-white rounded-lg shadow p-4">
<h3 class="text-sm font-medium text-gray-500">Wallet Status</h3>
<p class="mt-1 text-lg font-semibold" :class="{
'text-green-600': store.isWalletInitialized,
'text-gray-400': !store.isWalletInitialized
}">
{{ store.isWalletInitialized ? 'Connected' : 'Not Connected' }}
</p>
</div>
<div class="bg-white rounded-lg shadow p-4">
<h3 class="text-sm font-medium text-gray-500">Hydra Status</h3>
<p class="mt-1 text-lg font-semibold" :class="{
'text-green-600': store.isHydraConnected,
'text-yellow-600': store.headStatus === 'initializing',
'text-blue-600': store.headStatus === 'open',
'text-gray-400': !store.isHydraConnected
}">
{{ store.headStatus }}
</p>
</div>
<div class="bg-white rounded-lg shadow p-4">
<h3 class="text-sm font-medium text-gray-500">Balance</h3>
<p class="mt-1 text-lg font-semibold text-blue-600">
{{ store.formattedBalance }}
</p>
</div>
</div>
<!-- Tab Navigation -->
<div class="mb-6">
<nav class="flex space-x-8">
<button
v-for="tab in ['wallet', 'hydra', 'transactions']"
:key="tab"
@click="activeTab = tab"
:class="{
'border-blue-500 text-blue-600': activeTab === tab,
'border-transparent text-gray-500 hover:text-gray-700': activeTab !== tab
}"
class="py-2 px-1 border-b-2 font-medium text-sm capitalize"
>
{{ tab }}
</button>
</nav>
</div>
<!-- Tab Content -->
<div class="bg-white rounded-lg shadow-sm">
<WalletManager v-if="activeTab === 'wallet'" />
<HydraConnection v-if="activeTab === 'hydra'" />
<TransactionBuilder v-if="activeTab === 'transactions'" />
</div>
</main>
</div>
</template>
<style scoped>
/* Add any custom styles here */
</style>
Wire the WalletManager, HydraConnection, and TransactionBuilder child components to the store actions and getters shown above, and you have a full wallet, Hydra, and transaction UI.
Next steps
- Transactions — build, sign, and submit L1 and L2 transactions in depth
- Hydra Integration — connectors, the balance cache, and the
submitTxcallback API - Mint & Burn Tokens — native asset minting and burning
- Utilities Cookbook — serialization, address, and UTxO helpers
- Core API and Bridge API — full reference for
AppWalletandHydraBridge
