Complete Application with Hydra SDK
This example demonstrates how to build a complete application integrating all components of the Hydra SDK.
Application Structure
text
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
1. Wallet Service
typescript
// src/services/WalletService.ts
import { AppWallet, NETWORK_ID, UTxO } from '@hydra-sdk/core'
export class WalletService {
private wallet: AppWallet | null = null
private isInitialized = false
async createWallet(mnemonic?: string): Promise<AppWallet> {
const words = mnemonic ? mnemonic.split(' ') : AppWallet.brew()
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 {
const wallet = this.getWallet()
return wallet.mnemonic.join(' ')
}
getAddresses() {
const account = this.getAccount()
return {
base: account.baseAddressBech32,
stake: account.stakeAddressBech32,
enterprise: account.enterpriseAddressBech32
}
}
isWalletInitialized(): boolean {
return this.isInitialized && this.wallet !== null
}
}
export const walletService = new WalletService()
2. Hydra Service
typescript
// 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({
socketIoUrl: 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('onError', (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 'HeadIsInitializing':
this.headStatus = 'initializing'
this.emit('status', this.headStatus)
this.emit('initializing', { parties: payload.parties })
break
case 'HeadIsOpen':
this.headStatus = 'open'
this.emit('status', this.headStatus)
this.emit('headOpen', {
utxoCount: Object.keys(payload.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.queryUTxO()
}
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()
3. Transaction Service
typescript
// src/services/TransactionService.ts
import { TxBuilder } from '@hydra-sdk/transaction'
import { deserializeTx, 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
.selectUtxosFrom(utxos)
.txOut(request.recipient, outputs)
.changeAddress(account.baseAddressBech32)
// Add metadata if present
if (request.metadata) {
builder = builder.metadataValue(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 = 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
.selectUtxosFrom(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 deserializeTx(signedTx).transaction_hash().to_hex()
}
}
export const transactionService = new TransactionService()
4. Vue Store (Pinia)
typescript
// 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 = []
}
}
})
5. Main Vue Component
vue
<!-- 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>
6. Package Configuration
json
{
"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"
}
}
7. Vite Configuration
typescript
// 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']
}
})
Key Features
1. Complete Wallet Management
- Create/restore wallets from mnemonic
- Display addresses and balance
- Manage UTxOs and assets
2. Hydra Integration
- Connect to Hydra Bridge/Hexcore
- Manage Head lifecycle
- Handle real-time events
3. Transaction Builder
- L1 and L2 transactions
- Multi-asset support
- Contract interactions
- Metadata support
4. UI/UX
- Responsive design
- Real-time status updates
- Error handling
- Loading states
5. State Management
- Centralized store (Pinia)
- Reactive data binding
- Event-driven architecture
Best Practices Applied
- Service Layer Pattern: Separation of business logic
- Event-Driven Architecture: Loose coupling between components
- Error Boundary: Comprehensive error handling
- State Management: Centralized and reactive
- TypeScript: Type safety throughout codebase
- Modular Design: Reusable components and services
This application demonstrates how to integrate the entire Hydra SDK into a real-world project with good architecture and smooth user experience.
