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.
- 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
- 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
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]
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.
- Add Routstr provider using cashu-ts for wallet operations
- Store Routstr credentials and balance in auth.json
- Basic API requests with Bitcoin payments
File: /packages/opencode/package.json
{
"dependencies": {
"@cashu/cashu-ts": "^1.0.0"
}
}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)
}
}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)
}
}
}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;
}- 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
- Complete CLI wallet management using cashu-ts
- Lightning invoice generation and QR codes
- Balance tracking and top-up workflows
- Automatic wallet management for Routstr
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}`)
}
},
})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
}
}- 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
- Display wallet balance in Go TUI status bar
- Periodic balance updates from CLI
- Low balance warnings and prompts
- Seamless integration without complex IPC
The key insight is that the Go TUI can:
- Read cached balance from
auth.json(fast, for constant display) - Shell out to CLI periodically for fresh balance (accurate)
- Handle offline gracefully by showing cached data
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
}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
}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)
}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 ...
}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}`)
}
}
},
})- 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
- Single Language Wallet: All Cashu operations in TypeScript using mature cashu-ts
- No Complex IPC: No localhost API or HTTP endpoints between CLI and TUI
- Leveraged Infrastructure: Uses existing auth.json for state sharing
- Reduced Dependencies: No gonuts or additional Go libraries needed
- Simpler Testing: Wallet logic contained in TypeScript with familiar tooling
- 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
- 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
- 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
- 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
- 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
- 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
- All CLI wallet commands functional
- Lightning invoice generation with QR codes
- Automatic balance management
- Real-time auth.json balance updates
- Balance displayed in status bar
- Color-coded balance indicators
- Periodic balance refresh from CLI
- Graceful offline handling
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.