Guides

Build a Wallet App

Assemble a complete Cardano and Hydra Layer 2 wallet app — clone a ready-made React or Vue starter, or build the service layer yourself.

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:

  1. Initialize a wallet with AppWallet
  2. Get the account (address, keys)
  3. Build transactions with TxBuilder
  4. Optional Hydra — use HydraBridge to fetch protocol params and submit a tx to a Head
  5. Handle events and clean up (disconnect)
Hydra is optional. For an L1-only wallet you can skip @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

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:

  1. WASM loading errors — ensure vite-plugin-wasm is installed and configured, and that your browser supports WebAssembly.
  2. Buffer / process not defined — verify the vite-plugin-node-polyfills configuration and that globals are set correctly.
  3. Module resolution errors — check that all Hydra SDK packages are installed and the Vite config is correct (Vue also needs its path aliases configured).
  4. TypeScript compilation errors — run pnpm type-check, ensure all Hydra SDK types are imported, and verify your tsconfig.
  5. Build errors — ensure @hydra-sdk/cardano-wasm is excluded from optimization and all required plugins are in devDependencies.

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']
    }
})
Each plugin has a job: 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()
Hydra transactions use a different fee model. When 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