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

  1. Service Layer Pattern: Separation of business logic
  2. Event-Driven Architecture: Loose coupling between components
  3. Error Boundary: Comprehensive error handling
  4. State Management: Centralized and reactive
  5. TypeScript: Type safety throughout codebase
  6. 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.