Skip to content

Latest commit

 

History

History
1099 lines (892 loc) · 32.5 KB

File metadata and controls

1099 lines (892 loc) · 32.5 KB

Routstr Integration Plan - TypeScript Wallet Approach

Executive Summary

This is a simplified alternative to the original Routstr integration plan that eliminates the Go gonuts dependency and complex IPC by implementing all Cashu wallet functionality in TypeScript using cashu-ts. This approach maintains the same user-facing features while significantly reducing architectural complexity.

Key Simplifications vs Original Plan

What We Remove ❌

  • gonuts Go library dependency - No need for Go Cashu implementation
  • localhost:8000 API layer - No IPC between TypeScript CLI and Go TUI
  • Complex wallet manager in Go - All wallet logic stays in TypeScript
  • HTTP endpoints for wallet operations - Direct CLI commands instead

What We Keep ✅

  • Same 3-stage progression - Basic → Wallet → Full UX
  • Status bar balance display - Solved via auth.json state sharing
  • All user-facing features - Lightning invoices, QR codes, top-up flows
  • Security and privacy - Same Bitcoin Lightning benefits

Architecture Overview

graph TB
    CLI[TypeScript CLI<br/>cashu-ts wallet] --> AUTH[auth.json<br/>credentials + balance]
    CLI --> WALLET[wallet.json<br/>wallet state]
    
    TUI[Go TUI] --> AUTH
    TUI --> SHELL[Shell out to CLI<br/>for fresh balance]
    
    CLI --> ROUTSTR[Routstr API<br/>ai.routstr.com]
    TUI --> ROUTSTR
    
    CLI --> MINT[Cashu Mint<br/>mint.minibits.cash]
Loading

Key Insight: The Go TUI reads cached balance from auth.json for fast display, and optionally shells out to CLI commands for fresh data when needed.

Detailed Implementation Plan

Stage 1: Basic Integration (TypeScript-First)

Goals

  • Add Routstr provider using cashu-ts for wallet operations
  • Store Routstr credentials and balance in auth.json
  • Basic API requests with Bitcoin payments

Implementation Steps

1.1 Add cashu-ts Dependency

File: /packages/opencode/package.json

{
  "dependencies": {
    "@cashu/cashu-ts": "^1.0.0"
  }
}

1.2 Create Cashu Wallet Manager

File: /packages/opencode/src/wallet/cashu.ts

import { CashuMint, CashuWallet, getEncodedTokenV4, getDecodedToken } from '@cashu/cashu-ts'
import { Global } from '../global'
import path from 'path'
import fs from 'fs/promises'

export interface WalletConfig {
  mintUrl: string
  seed?: string
  balance: number
  lastUpdated: string
}

export class RoutstrCashuWallet {
  private wallet: CashuWallet
  private mint: CashuMint
  private configPath: string

  constructor(mintUrl: string = 'https://mint.minibits.cash/Bitcoin') {
    this.mint = new CashuMint(mintUrl)
    this.configPath = path.join(Global.Path.data, 'wallet.json')
  }

  async initialize(): Promise<void> {
    try {
      const config = await this.loadConfig()
      // Initialize wallet with existing seed if available
      this.wallet = new CashuWallet(this.mint, { 
        seed: config.seed ? Buffer.from(config.seed, 'hex') : undefined 
      })
    } catch {
      // Create new wallet if config doesn't exist
      this.wallet = new CashuWallet(this.mint)
      await this.saveConfig({
        mintUrl: this.mint.mintUrl,
        balance: 0,
        lastUpdated: new Date().toISOString(),
      })
    }
  }

  async receiveToken(encodedToken: string): Promise<number> {
    const decodedToken = getDecodedToken(encodedToken)
    const amount = decodedToken.token.reduce((sum, t) => sum + t.proofs.reduce((s, p) => s + p.amount, 0), 0)
    
    await this.wallet.receive(encodedToken)
    
    // Update config with new balance
    const config = await this.loadConfig()
    config.balance += amount
    config.lastUpdated = new Date().toISOString()
    await this.saveConfig(config)
    
    return amount
  }

  async getBalance(): Promise<number> {
    const config = await this.loadConfig()
    return config.balance
  }

  async createToken(amount: number): Promise<string> {
    const { send } = await this.wallet.send(amount)
    
    // Update config with reduced balance
    const config = await this.loadConfig()
    config.balance -= amount
    config.lastUpdated = new Date().toISOString()
    await this.saveConfig(config)
    
    return getEncodedTokenV4(send)
  }

  async createLightningInvoice(amount: number): Promise<{
    paymentRequest: string
    quote: string
  }> {
    const quote = await this.wallet.createMintQuote(amount)
    return {
      paymentRequest: quote.request,
      quote: quote.quote
    }
  }

  async checkInvoiceStatus(quote: string): Promise<boolean> {
    const quoteStatus = await this.wallet.checkMintQuote(quote)
    if (quoteStatus.paid) {
      // Mint the tokens
      await this.wallet.mintProofs(quoteStatus.amount, quote)
      
      // Update balance
      const config = await this.loadConfig()
      config.balance += quoteStatus.amount
      config.lastUpdated = new Date().toISOString()
      await this.saveConfig(config)
      
      return true
    }
    return false
  }

  private async loadConfig(): Promise<WalletConfig> {
    try {
      const content = await fs.readFile(this.configPath, 'utf8')
      return JSON.parse(content)
    } catch {
      throw new Error('Wallet not initialized')
    }
  }

  private async saveConfig(config: WalletConfig): Promise<void> {
    await fs.writeFile(this.configPath, JSON.stringify(config, null, 2))
    await fs.chmod(this.configPath, 0o600)
  }
}

1.3 Enhanced Routstr Auth Integration

File: /packages/opencode/src/auth/routstr.ts

import { RoutstrCashuWallet } from '../wallet/cashu'
import { Auth } from './index'

export interface RoutstrAuthData {
  type: 'routstr'
  apiKey: string
  baseUrl: string
  balance: number
  lastUpdated: string
}

export namespace RoutstrAuth {
  export async function createWalletFromToken(
    cashuToken: string, 
    baseUrl: string = 'https://api.routstr.com'
  ): Promise<{ apiKey: string; balance: number }> {
    
    // Initialize Cashu wallet
    const wallet = new RoutstrCashuWallet()
    await wallet.initialize()
    
    // Receive the provided token
    const receivedAmount = await wallet.receiveToken(cashuToken)
    
    // Create token for Routstr wallet
    const routstrToken = await wallet.createToken(receivedAmount)
    
    // Create Routstr wallet
    const response = await fetch(`${baseUrl}/v1/wallet/create`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ cashu_token: routstrToken }),
    })
    
    if (!response.ok) {
      throw new Error(`Failed to create Routstr wallet: ${response.status}`)
    }
    
    const result = await response.json()
    
    // Store in auth.json with balance info
    await Auth.set('routstr', {
      type: 'api',
      key: result.api_key,
    })
    
    // Store Routstr-specific metadata including balance
    await Auth.set('routstr-wallet', {
      type: 'routstr',
      apiKey: result.api_key,
      baseUrl,
      balance: result.balance,
      lastUpdated: new Date().toISOString(),
    } as RoutstrAuthData)
    
    return {
      apiKey: result.api_key,
      balance: result.balance,
    }
  }

  export async function getWalletInfo(): Promise<RoutstrAuthData | null> {
    try {
      const info = await Auth.get('routstr-wallet')
      return info as RoutstrAuthData
    } catch {
      return null
    }
  }

  export async function updateBalance(balance: number): Promise<void> {
    const info = await getWalletInfo()
    if (info) {
      info.balance = balance
      info.lastUpdated = new Date().toISOString()
      await Auth.set('routstr-wallet', info)
    }
  }
}

1.4 Update Auth Command for Routstr

File: /packages/opencode/src/cli/cmd/auth.ts

Add Routstr handling in AuthLoginCommand:

if (provider === "routstr") {
  const baseUrl = await prompts.text({
    message: "Enter Routstr base URL",
    initial: "https://api.routstr.com",
  });
  if (prompts.isCancel(baseUrl)) throw new UI.CancelledError();
  
  const cashuToken = await prompts.password({
    message: "Paste your Cashu token",
    validate: (x) => x?.startsWith("cashu") ? undefined : "Must be a valid Cashu token starting with 'cashu'",
  });
  if (prompts.isCancel(cashuToken)) throw new UI.CancelledError();
  
  const spinner = prompts.spinner();
  spinner.start("Creating wallet and funding Routstr account...");
  
  try {
    const result = await RoutstrAuth.createWalletFromToken(cashuToken, baseUrl);
    
    spinner.stop(`✅ Wallet created with ${result.balance} msats balance`);
    prompts.log.success(`API key: ${result.apiKey.substring(0, 20)}...`);
  } catch (error) {
    spinner.stop("❌ Failed to create wallet", 1);
    throw error;
  }
  
  prompts.outro("Done");
  return;
}

Expected Deliverables Stage 1

  • Routstr appears in provider list with cashu-ts integration
  • Can create wallet from Cashu token
  • Balance stored in auth.json for TUI access
  • Basic API requests work through Routstr

Stage 2: Full TypeScript Wallet Commands

Goals

  • Complete CLI wallet management using cashu-ts
  • Lightning invoice generation and QR codes
  • Balance tracking and top-up workflows
  • Automatic wallet management for Routstr

Implementation Steps

2.1 Add Wallet CLI Commands

File: /packages/opencode/src/cli/cmd/wallet.ts

import { RoutstrCashuWallet } from '../../wallet/cashu'
import { RoutstrAuth } from '../../auth/routstr'
import { cmd } from './cmd'
import * as prompts from '@clack/prompts'
import QRCode from 'qrcode'
import { UI } from '../ui'

export const WalletCommand = cmd({
  command: "wallet",
  describe: "manage cashu wallet",
  builder: (yargs) =>
    yargs
      .command(WalletBalanceCommand)
      .command(WalletTopupCommand) 
      .command(WalletWithdrawCommand)
      .command(WalletStatusCommand)
      .demandCommand(),
  async handler() {},
})

export const WalletBalanceCommand = cmd({
  command: "balance",
  describe: "show wallet balance",
  async handler() {
    try {
      const wallet = new RoutstrCashuWallet()
      await wallet.initialize()
      
      const localBalance = await wallet.getBalance()
      const routstrInfo = await RoutstrAuth.getWalletInfo()
      
      prompts.intro("Wallet Balance")
      
      prompts.log.info(`Local Cashu Wallet: ${localBalance} sats`)
      
      if (routstrInfo) {
        prompts.log.info(`Routstr Account: ${routstrInfo.balance} msats`)
        prompts.log.info(`Last updated: ${new Date(routstrInfo.lastUpdated).toLocaleString()}`)
      }
      
      const totalSats = localBalance + (routstrInfo?.balance || 0) / 1000
      prompts.outro(`Total: ${totalSats} sats`)
      
    } catch (error) {
      prompts.log.error(`Error: ${error.message}`)
    }
  },
})

export const WalletTopupCommand = cmd({
  command: "topup [token]",
  describe: "add funds to wallet",
  builder: (yargs) =>
    yargs.positional("token", {
      describe: "cashu token to add, or generate Lightning invoice",
      type: "string",
    }),
  async handler(args) {
    const wallet = new RoutstrCashuWallet()
    await wallet.initialize()
    
    if (args.token) {
      // Add existing Cashu token
      try {
        const amount = await wallet.receiveToken(args.token)
        prompts.log.success(`✅ Added ${amount} sats to wallet`)
      } catch (error) {
        prompts.log.error(`❌ Failed to receive token: ${error.message}`)
      }
    } else {
      // Generate Lightning invoice
      const amount = await prompts.text({
        message: "How many sats to add?",
        initial: "10000",
        validate: (x) => !isNaN(Number(x)) && Number(x) > 0 ? undefined : "Must be a positive number",
      })
      if (prompts.isCancel(amount)) throw new UI.CancelledError()
      
      const satsAmount = parseInt(amount)
      
      try {
        const invoice = await wallet.createLightningInvoice(satsAmount)
        
        prompts.intro(`Lightning Invoice for ${satsAmount} sats`)
        
        // Generate and display QR code
        const qrCode = await QRCode.toString(invoice.paymentRequest, { type: 'terminal' })
        console.log(qrCode)
        
        prompts.log.info("Or copy this invoice:")
        console.log(invoice.paymentRequest)
        
        // Poll for payment
        const spinner = prompts.spinner()
        spinner.start("Waiting for payment...")
        
        const pollInterval = setInterval(async () => {
          try {
            const paid = await wallet.checkInvoiceStatus(invoice.quote)
            if (paid) {
              clearInterval(pollInterval)
              spinner.stop("✅ Payment received!")
              
              // Update Routstr balance
              await RoutstrAuth.updateBalance(await wallet.getBalance())
              
              prompts.outro(`Added ${satsAmount} sats to wallet`)
            }
          } catch (error) {
            clearInterval(pollInterval)
            spinner.stop("❌ Error checking payment", 1)
            prompts.log.error(error.message)
          }
        }, 2000)
        
        // Timeout after 5 minutes
        setTimeout(() => {
          clearInterval(pollInterval)
          spinner.stop("⏰ Payment timeout", 1)
        }, 300000)
        
      } catch (error) {
        prompts.log.error(`❌ Failed to create invoice: ${error.message}`)
      }
    }
  },
})

export const WalletWithdrawCommand = cmd({
  command: "withdraw [amount]",
  describe: "withdraw sats as cashu token",
  builder: (yargs) =>
    yargs.positional("amount", {
      describe: "amount in sats (default: all)",
      type: "number",
    }),
  async handler(args) {
    const wallet = new RoutstrCashuWallet()
    await wallet.initialize()
    
    const balance = await wallet.getBalance()
    if (balance === 0) {
      prompts.log.error("Wallet is empty")
      return
    }
    
    const amount = args.amount || balance
    
    if (amount > balance) {
      prompts.log.error(`Insufficient balance. Available: ${balance} sats`)
      return
    }
    
    try {
      const token = await wallet.createToken(amount)
      
      prompts.intro(`Withdraw ${amount} sats`)
      prompts.log.success("Cashu token:")
      console.log(token)
      prompts.outro("Token can be shared or imported into another wallet")
      
    } catch (error) {
      prompts.log.error(`❌ Failed to withdraw: ${error.message}`)
    }
  },
})

export const WalletStatusCommand = cmd({
  command: "status",
  describe: "show detailed wallet status", 
  async handler() {
    try {
      const wallet = new RoutstrCashuWallet()
      await wallet.initialize()
      
      const localBalance = await wallet.getBalance()
      const routstrInfo = await RoutstrAuth.getWalletInfo()
      
      prompts.intro("Wallet Status")
      
      // Local wallet info
      prompts.log.info(`📱 Local Cashu Wallet`)
      prompts.log.info(`   Balance: ${localBalance} sats`)
      prompts.log.info(`   Mint: ${wallet.mint.mintUrl}`)
      
      // Routstr account info  
      if (routstrInfo) {
        prompts.log.info(`⚡ Routstr Account`) 
        prompts.log.info(`   API Key: ${routstrInfo.apiKey.substring(0, 20)}...`)
        prompts.log.info(`   Balance: ${routstrInfo.balance} msats`)
        prompts.log.info(`   Base URL: ${routstrInfo.baseUrl}`)
        prompts.log.info(`   Last Updated: ${new Date(routstrInfo.lastUpdated).toLocaleString()}`)
      } else {
        prompts.log.warn("No Routstr account configured")
      }
      
      prompts.outro("Use 'opencode wallet balance' for quick balance check")
      
    } catch (error) {
      prompts.log.error(`Error: ${error.message}`)
    }
  },
})

2.2 Automatic Routstr Balance Management

File: /packages/opencode/src/wallet/routstr.ts

import { RoutstrAuth } from '../auth/routstr'
import { RoutstrCashuWallet } from './cashu'

export class RoutstrBalanceManager {
  private wallet: RoutstrCashuWallet
  private minBalance: number = 5000 // 5k msats minimum

  constructor() {
    this.wallet = new RoutstrCashuWallet()
  }

  async ensureSufficientBalance(requiredAmount: number): Promise<boolean> {
    const routstrInfo = await RoutstrAuth.getWalletInfo()
    if (!routstrInfo) {
      throw new Error('No Routstr account configured. Run: opencode auth login')
    }

    // Check if current balance is sufficient
    if (routstrInfo.balance >= requiredAmount + this.minBalance) {
      return true
    }

    // Need to top up from local wallet
    const localBalance = await this.wallet.getBalance()
    const neededAmount = Math.max(requiredAmount + this.minBalance - routstrInfo.balance, 0)
    const topupAmount = Math.min(neededAmount, localBalance * 1000) // Convert sats to msats

    if (topupAmount === 0) {
      throw new Error(`Insufficient funds. Need ${neededAmount} msats more. Run: opencode wallet topup`)
    }

    // Create token and top up Routstr account
    const tokenAmount = Math.ceil(topupAmount / 1000) // Convert back to sats
    const cashuToken = await this.wallet.createToken(tokenAmount)

    // Send to Routstr
    const response = await fetch(`${routstrInfo.baseUrl}/v1/wallet/topup`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${routstrInfo.apiKey}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ cashu_token: cashuToken }),
    })

    if (!response.ok) {
      throw new Error(`Failed to top up Routstr account: ${response.status}`)
    }

    const result = await response.json()
    
    // Update stored balance
    await RoutstrAuth.updateBalance(result.balance)

    return true
  }

  async getRoutstrBalance(): Promise<number> {
    const routstrInfo = await RoutstrAuth.getWalletInfo()
    if (!routstrInfo) return 0

    try {
      const response = await fetch(`${routstrInfo.baseUrl}/v1/wallet/balance`, {
        headers: { 'Authorization': `Bearer ${routstrInfo.apiKey}` },
      })

      if (response.ok) {
        const result = await response.json()
        await RoutstrAuth.updateBalance(result.balance)
        return result.balance
      }
    } catch {
      // Fall back to cached balance
    }

    return routstrInfo.balance
  }
}

Expected Deliverables Stage 2

  • Complete wallet CLI commands with Lightning support
  • QR code generation for top-up invoices
  • Automatic balance management between local and Routstr wallets
  • Real-time balance tracking stored in auth.json

Stage 3: Go TUI Status Bar Integration

Goals

  • Display wallet balance in Go TUI status bar
  • Periodic balance updates from CLI
  • Low balance warnings and prompts
  • Seamless integration without complex IPC

Solution: Auth.json + Shell Commands

The key insight is that the Go TUI can:

  1. Read cached balance from auth.json (fast, for constant display)
  2. Shell out to CLI periodically for fresh balance (accurate)
  3. Handle offline gracefully by showing cached data

Implementation Steps

3.1 Update Status Component for Wallet Balance

File: /packages/tui/internal/components/status/status.go

type statusComponent struct {
    app           *app.App
    width         int
    cwd           string
    branch        string
    walletBalance int64          // Add wallet balance (in msats)
    walletUpdated time.Time      // Track when balance was updated
    watcher       *fsnotify.Watcher
    done          chan struct{}
    lastUpdate    time.Time
}

// Add wallet balance message type
type WalletBalanceUpdatedMsg struct {
    Balance int64
    Updated time.Time
}

func (m *statusComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.WindowSizeMsg:
        m.width = msg.Width
        return m, nil
    case GitBranchUpdatedMsg:
        if m.branch != msg.Branch {
            m.branch = msg.Branch
        }
        return m, m.watchForGitChanges()
    case WalletBalanceUpdatedMsg:  // Add wallet balance handling
        m.walletBalance = msg.Balance
        m.walletUpdated = msg.Updated
        return m, nil
    }
    return m, nil
}

func (m *statusComponent) View() string {
    t := theme.CurrentTheme()
    logo := m.logo()
    logoWidth := lipgloss.Width(logo)

    // ... existing agent styling code ...

    // Add wallet balance display
    var walletDisplay string
    if m.walletBalance > 0 {
        sats := m.walletBalance / 1000 // Convert msats to sats for display
        walletDisplay = fmt.Sprintf("⚡%d", sats)
        
        // Show different colors based on balance
        var walletStyle lipgloss.Style
        if sats < 1000 {
            walletStyle = styles.NewStyle().Foreground(t.ErrorColor()) // Red for low balance
        } else if sats < 5000 {
            walletStyle = styles.NewStyle().Foreground(t.WarningColor()) // Yellow for medium
        } else {
            walletStyle = styles.NewStyle().Foreground(t.AccentColor()) // Blue for good balance
        }
        
        walletDisplay = walletStyle.Render(walletDisplay)
    }

    // Calculate available width for path display
    availableWidth := m.width - logoWidth - modeWidth
    if walletDisplay != "" {
        availableWidth -= lipgloss.Width(walletDisplay) + 3 // Account for wallet + spacing
    }
    
    branchSuffix := ""
    if m.branch != "" {
        branchSuffix = ":" + m.branch
    }

    maxCwdWidth := availableWidth - lipgloss.Width(branchSuffix)
    cwdDisplay := m.collapsePath(m.cwd, maxCwdWidth)

    if m.branch != "" && availableWidth > lipgloss.Width(cwdDisplay)+lipgloss.Width(branchSuffix) {
        cwdDisplay += faintStyle.Render(branchSuffix)
    }

    // Build left content with wallet balance
    leftContent := logo + styles.NewStyle().
        Foreground(t.TextMuted()).
        Background(t.BackgroundPanel()).
        Padding(0, 1).
        Render(cwdDisplay)
    
    if walletDisplay != "" {
        leftContent += faintStyle.Render(" | ") + walletDisplay
    }

    background := t.BackgroundPanel()
    status := layout.Render(
        layout.FlexOptions{
            Background: &background,
            Direction:  layout.Row,
            Justify:    layout.JustifySpaceBetween,
            Align:      layout.AlignStretch,
            Width:      m.width,
        },
        layout.FlexItem{
            View: leftContent,
        },
        layout.FlexItem{
            View: agent,
        },
    )

    blank := styles.NewStyle().Background(t.Background()).Width(m.width).Render("")
    return blank + "\n" + status
}

3.2 Create Wallet Balance Reader

File: /packages/tui/internal/wallet/reader.go

package wallet

import (
    "encoding/json"
    "os"
    "os/exec"
    "path/filepath"
    "strconv"
    "strings"
    "time"
    
    "github.com/sst/opencode/internal/global"
)

type RoutstrWalletData struct {
    Type        string `json:"type"`
    APIKey      string `json:"apiKey"`
    BaseURL     string `json:"baseUrl"`
    Balance     int64  `json:"balance"`
    LastUpdated string `json:"lastUpdated"`
}

type AuthData struct {
    RoutstrWallet RoutstrWalletData `json:"routstr-wallet"`
}

// Read cached balance from auth.json
func GetCachedBalance() (int64, time.Time, error) {
    authPath := filepath.Join(global.Path.data, "auth.json")
    
    data, err := os.ReadFile(authPath)
    if err != nil {
        return 0, time.Time{}, err
    }
    
    var auth AuthData
    if err := json.Unmarshal(data, &auth); err != nil {
        return 0, time.Time{}, err
    }
    
    updated, _ := time.Parse(time.RFC3339, auth.RoutstrWallet.LastUpdated)
    return auth.RoutstrWallet.Balance, updated, nil
}

// Get fresh balance by shelling out to CLI
func GetFreshBalance() (int64, error) {
    cmd := exec.Command("opencode", "wallet", "balance", "--json")
    output, err := cmd.Output()
    if err != nil {
        return 0, err
    }
    
    // Parse CLI output to extract balance
    lines := strings.Split(strings.TrimSpace(string(output)), "\n")
    for _, line := range lines {
        if strings.Contains(line, "Routstr Account:") {
            // Extract balance from line like "Routstr Account: 12345 msats"
            parts := strings.Fields(line)
            if len(parts) >= 3 {
                balanceStr := parts[2]
                if balance, err := strconv.ParseInt(balanceStr, 10, 64); err == nil {
                    return balance, nil
                }
            }
        }
    }
    
    return 0, fmt.Errorf("could not parse balance from CLI output")
}

// Check if wallet is configured
func IsWalletConfigured() bool {
    balance, _, err := GetCachedBalance()
    return err == nil && balance > 0
}

3.3 Add Balance Monitor

File: /packages/tui/internal/wallet/monitor.go

package wallet

import (
    "context"
    "time"
    
    tea "github.com/charmbracelet/bubbletea/v2"
)

type BalanceMonitor struct {
    program       *tea.Program
    refreshInterval time.Duration
    done          chan struct{}
}

func NewBalanceMonitor(program *tea.Program) *BalanceMonitor {
    return &BalanceMonitor{
        program:       program,
        refreshInterval: 30 * time.Second, // Check every 30 seconds
        done:          make(chan struct{}),
    }
}

func (bm *BalanceMonitor) Start(ctx context.Context) {
    // Send initial balance from cache
    if balance, updated, err := GetCachedBalance(); err == nil {
        bm.program.Send(WalletBalanceUpdatedMsg{
            Balance: balance,
            Updated: updated,
        })
    }
    
    ticker := time.NewTicker(bm.refreshInterval)
    defer ticker.Stop()
    
    for {
        select {
        case <-ticker.C:
            // Try fresh balance first
            if balance, err := GetFreshBalance(); err == nil {
                bm.program.Send(WalletBalanceUpdatedMsg{
                    Balance: balance,
                    Updated: time.Now(),
                })
            } else {
                // Fall back to cached balance
                if balance, updated, err := GetCachedBalance(); err == nil {
                    bm.program.Send(WalletBalanceUpdatedMsg{
                        Balance: balance,
                        Updated: updated,
                    })
                }
            }
            
        case <-bm.done:
            return
        case <-ctx.Done():
            return
        }
    }
}

func (bm *BalanceMonitor) Stop() {
    close(bm.done)
}

3.4 Integrate Balance Monitor into TUI

File: /packages/tui/internal/tui/tui.go

import (
    "github.com/sst/opencode/internal/wallet"
)

type Model struct {
    // ... existing fields ...
    balanceMonitor *wallet.BalanceMonitor
}

func NewModel(app *app.App) tea.Model {
    model := &Model{
        app:    app,
        // ... other initialization ...
    }
    
    // Initialize balance monitor if wallet is configured
    if wallet.IsWalletConfigured() {
        model.balanceMonitor = wallet.NewBalanceMonitor(program)
    }
    
    return model
}

func (m *Model) Init() tea.Cmd {
    var cmds []tea.Cmd
    
    // ... existing init commands ...
    
    // Start balance monitoring if configured
    if m.balanceMonitor != nil {
        go m.balanceMonitor.Start(context.Background())
    }
    
    return tea.Batch(cmds...)
}

func (m *Model) Cleanup() {
    if m.balanceMonitor != nil {
        m.balanceMonitor.Stop()
    }
    // ... existing cleanup ...
}

3.5 Add Enhanced Wallet Balance CLI Command

File: /packages/opencode/src/cli/cmd/wallet.ts

Update WalletBalanceCommand to support JSON output:

export const WalletBalanceCommand = cmd({
  command: "balance",
  describe: "show wallet balance",
  builder: (yargs) =>
    yargs.option("json", {
      describe: "output as JSON",
      type: "boolean",
      default: false,
    }),
  async handler(args) {
    try {
      const wallet = new RoutstrCashuWallet()
      await wallet.initialize()
      
      const localBalance = await wallet.getBalance()
      const routstrInfo = await RoutstrAuth.getWalletInfo()
      
      if (args.json) {
        // JSON output for TUI consumption
        console.log(JSON.stringify({
          local_balance: localBalance,
          routstr_balance: routstrInfo?.balance || 0,
          total_sats: localBalance + (routstrInfo?.balance || 0) / 1000,
          last_updated: routstrInfo?.lastUpdated || null,
        }))
      } else {
        // Human-friendly output
        prompts.intro("Wallet Balance")
        prompts.log.info(`Local Cashu Wallet: ${localBalance} sats`)
        
        if (routstrInfo) {
          prompts.log.info(`Routstr Account: ${routstrInfo.balance} msats`)
          prompts.log.info(`Last updated: ${new Date(routstrInfo.lastUpdated).toLocaleString()}`)
        }
        
        const totalSats = localBalance + (routstrInfo?.balance || 0) / 1000
        prompts.outro(`Total: ${totalSats} sats`)
      }
      
      // Always update auth.json with latest info
      if (routstrInfo) {
        await RoutstrAuth.updateBalance(routstrInfo.balance)
      }
      
    } catch (error) {
      if (args.json) {
        console.log(JSON.stringify({ error: error.message }))
      } else {
        prompts.log.error(`Error: ${error.message}`)
      }
    }
  },
})

Expected Deliverables Stage 3

  • Wallet balance displayed in status bar: ⚡1,234 sats
  • Color-coded balance (red=low, yellow=medium, blue=good)
  • Periodic balance updates from CLI without blocking UI
  • Graceful handling of offline/error states
  • JSON CLI output for TUI integration

Architecture Benefits

Simplifications Achieved ✅

  1. Single Language Wallet: All Cashu operations in TypeScript using mature cashu-ts
  2. No Complex IPC: No localhost API or HTTP endpoints between CLI and TUI
  3. Leveraged Infrastructure: Uses existing auth.json for state sharing
  4. Reduced Dependencies: No gonuts or additional Go libraries needed
  5. Simpler Testing: Wallet logic contained in TypeScript with familiar tooling

Stage 3 Solution Elegance ✅

  • Fast Display: Go TUI reads cached balance from auth.json instantly
  • Accurate Updates: Periodic CLI shell-out ensures fresh data
  • Offline Resilience: Cached balance shown when CLI unavailable
  • Low Overhead: Balance updates only when needed, no constant polling

Security Considerations

Wallet Security 🔒

  • Encrypted Storage: Cashu-ts handles secure key management
  • File Permissions: auth.json and wallet.json restricted to user (600)
  • No Token Logging: Never log Cashu tokens or seed phrases
  • Secure Defaults: Safe mint URLs and conservative balance thresholds

Network Security 🌐

  • HTTPS Only: All Routstr communication over TLS
  • Token Validation: Validate Cashu tokens before processing
  • Rate Limiting: Respect Routstr rate limits and implement backoff
  • Error Handling: Never expose sensitive data in error messages

Testing Strategy

Unit Tests 🧪

  • Cashu Operations: Token creation, receipt, balance tracking
  • CLI Commands: All wallet commands with mocked dependencies
  • Auth Integration: Reading/writing auth.json wallet data
  • Balance Calculations: Sats/msats conversions and display logic

Integration Tests 🔗

  • End-to-End Auth: Full login flow with test tokens
  • CLI ↔ TUI Communication: Auth.json state sharing
  • Balance Updates: CLI updates reflected in TUI status bar
  • Error Scenarios: Network failures, invalid tokens, insufficient balance

Success Metrics

Stage 1: Basic Integration ✅

  • Routstr appears in opencode auth login
  • Can authenticate with Cashu token using cashu-ts
  • Wallet info stored in auth.json
  • API requests work through Routstr

Stage 2: Full Wallet ✅

  • All CLI wallet commands functional
  • Lightning invoice generation with QR codes
  • Automatic balance management
  • Real-time auth.json balance updates

Stage 3: TUI Integration ✅

  • Balance displayed in status bar
  • Color-coded balance indicators
  • Periodic balance refresh from CLI
  • Graceful offline handling

Conclusion

This simplified TypeScript-first approach eliminates significant architectural complexity while maintaining all the user-facing benefits:

  • Simpler Architecture: Single-language wallet using mature cashu-ts
  • No Complex IPC: Leverages existing auth.json for state sharing
  • Full Feature Set: Lightning invoices, QR codes, balance tracking
  • Status Bar Integration: Elegant solution via cached balance + CLI refresh
  • Reduced Dependencies: No gonuts or HTTP server needed

The key insight is that the Go TUI doesn't need real-time wallet operations - it just needs to display current balance and handle user interactions. The TypeScript CLI handles all the complex Cashu operations, and simple file-based state sharing provides the necessary integration.

This approach provides the same Bitcoin Lightning payment capabilities with significantly reduced implementation complexity, making it easier to develop, test, and maintain.