This document outlines a comprehensive 3-stage plan to integrate Routstr (a Bitcoin eCash payment proxy for AI APIs) into OpenCode. The integration will enable users to pay for AI model requests using Bitcoin Lightning Network payments through Cashu eCash tokens, providing anonymous, instant micropayments without requiring traditional payment methods.
- Auth storage:
/Users/{user}/.opencode/auth.json(600 permissions) - Auth types: OAuth, API key, WellKnown
- Provider system: Dynamic provider loading from models.dev API
- CLI integration:
opencode auth logincommand with provider selection - TUI integration: Go-based terminal UI with status bar
- Payment model: Pre-funded accounts using Cashu eCash tokens
- API compatibility: Full OpenAI API compatibility
- Token usage: Two flows - persistent API keys or direct token usage
- Cost calculation: Per-token billing with BTC/USD conversion
- Refund system: Unused balance can be withdrawn as eCash tokens
- Token lifecycle management: Cashu tokens are spent/consumed on use
- Balance tracking: Real-time balance monitoring and low-balance handling
- Top-up workflow: Seamless refill experience
- Status bar integration: Show wallet balance and payment status
- Refund handling: Withdraw unused funds
Based on the Routstr documentation and system architecture, here are three different approaches for handling token management and user experience:
Philosophy: Create persistent API keys backed by Cashu wallet, similar to traditional API key workflow.
Flow:
- User provides Cashu token during
opencode auth login - OpenCode calls Routstr
/v1/wallet/createto create persistent API key - Store the returned API key (
sk-...) in auth.json like traditional providers - Use standard API key flow for all requests
- Implement background balance checking and top-up prompts
- Support refund withdrawals through wallet commands
Pros:
- Familiar user experience (like existing API keys)
- Persistent sessions across restarts
- Clean separation between payment and request logic
- Easy to implement existing provider patterns
Cons:
- Requires additional wallet management endpoints
- More complex state management
Philosophy: Use Cashu tokens directly as API keys, rotating tokens as needed.
Flow:
- User provides Cashu token during
opencode auth login - Store token directly as API key in auth.json
- Use token as Authorization header for each request
- Handle insufficient balance errors by prompting for new token
- Automatically handle refunds and token rotation
Pros:
- Simpler implementation (no additional endpoints needed)
- Direct token-to-request mapping
- Immediate feedback on token exhaustion
Cons:
- Requires token rotation logic
- Less predictable user experience
- Token management complexity
Philosophy: Combine persistent wallet with automatic token management.
Flow:
- Create persistent wallet ID but manage tokens internally
- Automatic token rotation and balance aggregation
- Show aggregated balance across multiple tokens
- Seamless top-up without user intervention for small amounts
Pros:
- Best user experience
- Automatic token management
- Persistent identity with flexible payments
Cons:
- Most complex implementation
- Requires sophisticated token juggling logic
After analyzing the trade-offs, Approach A provides the best balance of implementation complexity and user experience. It leverages existing OpenCode patterns while providing a clean abstraction over the underlying payment complexity.
- Add Routstr as a new provider option in
opencode auth login - Support basic API requests with Cashu token authentication
- Minimal viable product for testing
File: /packages/opencode/src/cli/cmd/auth.ts
Add Routstr to the provider priority list:
const priority: Record<string, number> = {
anthropic: 0,
"github-copilot": 1,
openai: 2,
google: 3,
routstr: 4, // Add here
openrouter: 5,
vercel: 6,
}File: /packages/opencode/src/auth/routstr.ts
export namespace RoutstrAuth {
export async function createWallet(cashuToken: string, baseUrl: string): Promise<{
apiKey: string;
balance: number;
}> {
const response = await fetch(`${baseUrl}/v1/wallet/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cashu_token: cashuToken }),
});
if (!response.ok) {
throw new Error(`Failed to create wallet: ${response.status}`);
}
return response.json();
}
export async function checkBalance(apiKey: string, baseUrl: string): Promise<{
balance: number;
total_deposited: number;
total_spent: number;
}> {
const response = await fetch(`${baseUrl}/v1/wallet/balance`, {
headers: { 'Authorization': `Bearer ${apiKey}` },
});
return response.json();
}
}File: /packages/opencode/src/cli/cmd/auth.ts
Add special 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",
});
if (prompts.isCancel(cashuToken)) throw new UI.CancelledError();
const spinner = prompts.spinner();
spinner.start("Creating wallet...");
try {
const wallet = await RoutstrAuth.createWallet(cashuToken, baseUrl);
await Auth.set("routstr", {
type: "api",
key: wallet.apiKey,
});
// Store additional metadata
await Auth.set("routstr-meta", {
type: "api",
key: JSON.stringify({
baseUrl,
balance: wallet.balance,
created: new Date().toISOString(),
}),
});
spinner.stop(`Wallet created with ${wallet.balance} sats`);
} catch (error) {
spinner.stop("Failed to create wallet", 1);
throw error;
}
prompts.outro("Done");
return;
}File: /packages/opencode/src/provider/models.ts
Ensure Routstr appears in provider listings by updating the local provider data or adding fallback provider info.
- Test
opencode auth loginwith Routstr option - Verify wallet creation with test Cashu tokens
- Confirm API requests work with generated API keys
- Routstr appears in
opencode auth list - Can create wallet with Cashu token
- Basic API requests work
- Error handling for invalid tokens
- Integrate gonuts library for internal wallet management
- Store and manage Cashu tokens internally
- Automatic token provisioning for API requests
- Better balance tracking and management
File: /packages/tui/go.mod
require (
github.com/elnosh/gonuts v0.x.x // Check latest version
)File: /packages/tui/internal/wallet/cashu.go
package wallet
import (
"context"
"fmt"
"path/filepath"
"github.com/elnosh/gonuts/wallet"
"github.com/sst/opencode/internal/global"
)
type CashuWallet struct {
wallet *wallet.Wallet
config *wallet.Config
}
func NewCashuWallet() (*CashuWallet, error) {
configDir := Global.Path.data
dbPath := filepath.Join(configDir, "cashu.db")
config := &wallet.Config{
WalletDataPath: dbPath,
CurrentMintURL: "https://mint.minibits.cash/Bitcoin",
}
w, err := wallet.LoadWallet(config)
if err != nil {
// Create new wallet if doesn't exist
w, err = wallet.CreateWallet(config)
if err != nil {
return nil, fmt.Errorf("failed to create wallet: %w", err)
}
}
return &CashuWallet{
wallet: w,
config: config,
}, nil
}
func (cw *CashuWallet) Balance(ctx context.Context) (uint64, error) {
return cw.wallet.GetBalance()
}
func (cw *CashuWallet) ReceiveToken(ctx context.Context, token string) (uint64, error) {
return cw.wallet.ReceiveTokens(token)
}
func (cw *CashuWallet) CreateToken(ctx context.Context, amount uint64) (string, error) {
return cw.wallet.CreateToken(amount)
}
func (cw *CashuWallet) SendToken(ctx context.Context, amount uint64) (string, error) {
return cw.wallet.SendTokens(amount)
}File: /packages/tui/internal/wallet/routstr.go
package wallet
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
)
type RoutstrProvider struct {
baseURL string
wallet *CashuWallet
currentKey string
}
func NewRoutstrProvider(baseURL string, wallet *CashuWallet) *RoutstrProvider {
return &RoutstrProvider{
baseURL: baseURL,
wallet: wallet,
}
}
func (rp *RoutstrProvider) EnsureWallet(ctx context.Context) error {
if rp.currentKey != "" {
// Check if current key is still valid
balance, err := rp.checkBalance(ctx)
if err == nil && balance > 0 {
return nil // Current key is good
}
}
// Create new wallet from current balance
balance, err := rp.wallet.Balance(ctx)
if err != nil {
return fmt.Errorf("failed to check wallet balance: %w", err)
}
if balance == 0 {
return fmt.Errorf("wallet is empty, please top up")
}
// Create token for all balance
token, err := rp.wallet.CreateToken(ctx, balance)
if err != nil {
return fmt.Errorf("failed to create token: %w", err)
}
// Create Routstr wallet
apiKey, err := rp.createWallet(ctx, token)
if err != nil {
return fmt.Errorf("failed to create Routstr wallet: %w", err)
}
rp.currentKey = apiKey
return nil
}
func (rp *RoutstrProvider) createWallet(ctx context.Context, cashuToken string) (string, error) {
reqBody := map[string]string{
"cashu_token": cashuToken,
}
jsonBody, _ := json.Marshal(reqBody)
resp, err := http.Post(rp.baseURL+"/v1/wallet/create", "application/json", bytes.NewBuffer(jsonBody))
if err != nil {
return "", err
}
defer resp.Body.Close()
var result struct {
APIKey string `json:"api_key"`
Balance int64 `json:"balance"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
return result.APIKey, nil
}
func (rp *RoutstrProvider) checkBalance(ctx context.Context) (int64, error) {
req, _ := http.NewRequestWithContext(ctx, "GET", rp.baseURL+"/v1/wallet/balance", nil)
req.Header.Set("Authorization", "Bearer "+rp.currentKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
var result struct {
Balance int64 `json:"balance"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return 0, err
}
return result.Balance, nil
}
func (rp *RoutstrProvider) GetAPIKey(ctx context.Context) (string, error) {
if err := rp.EnsureWallet(ctx); err != nil {
return "", err
}
return rp.currentKey, nil
}File: /packages/opencode/src/auth/routstr.ts
export interface RoutstrWalletConfig {
baseUrl: string;
mintUrl?: string;
balance: number;
lastUpdated: string;
}
export async function initializeWallet(cashuToken: string, config: RoutstrWalletConfig): Promise<void> {
// Call TUI wallet initialization endpoint
const response = await fetch('http://localhost:8000/wallet/initialize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cashu_token: cashuToken,
base_url: config.baseUrl,
mint_url: config.mintUrl,
}),
});
if (!response.ok) {
throw new Error(`Failed to initialize wallet: ${response.status}`);
}
}File: /packages/opencode/src/cli/cmd/wallet.ts
export const WalletCommand = cmd({
command: "wallet",
describe: "manage cashu wallet",
builder: (yargs) =>
yargs
.command(WalletBalanceCommand)
.command(WalletTopupCommand)
.command(WalletWithdrawCommand)
.demandCommand(),
async handler() {},
})
export const WalletBalanceCommand = cmd({
command: "balance",
describe: "show wallet balance",
async handler() {
// Call TUI wallet balance endpoint
const response = await fetch('http://localhost:8000/wallet/balance');
const data = await response.json();
console.log(`Balance: ${data.balance} sats`);
},
})
export const WalletTopupCommand = cmd({
command: "topup <token>",
describe: "add cashu token to wallet",
builder: (yargs) =>
yargs.positional("token", {
describe: "cashu token to add",
type: "string",
}),
async handler(args) {
const response = await fetch('http://localhost:8000/wallet/topup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cashu_token: args.token }),
});
const data = await response.json();
console.log(`Added ${data.amount} sats. New balance: ${data.balance} sats`);
},
})- Internal Cashu wallet using gonuts
- Automatic token management
- CLI wallet commands (
opencode wallet balance,topup,withdraw) - Seamless API key provisioning from wallet
- Display wallet balance in status bar
- QR code generation for Lightning invoices
- Automatic top-up prompts
- Refund handling
- Complete user experience
File: /packages/tui/internal/components/status/status.go
Add wallet balance field to statusComponent:
type statusComponent struct {
app *app.App
width int
cwd string
branch string
walletBalance int64 // Add this
watcher *fsnotify.Watcher
done chan struct{}
lastUpdate time.Time
}Update View() method to include balance:
func (m *statusComponent) View() string {
// ... existing code ...
// Add wallet balance display
var balanceDisplay string
if m.walletBalance > 0 {
balanceDisplay = fmt.Sprintf("⚡%d sats", m.walletBalance)
}
// Update layout to include balance
leftContent := logo + cwd
if balanceDisplay != "" {
leftContent += faintStyle.Render(" | ") +
styles.NewStyle().Foreground(t.AccentColor()).Render(balanceDisplay)
}
// ... rest of layout code ...
}Add balance update message handling:
type WalletBalanceUpdatedMsg struct {
Balance int64
}
func (m *statusComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case WalletBalanceUpdatedMsg:
m.walletBalance = msg.Balance
return m, nil
// ... existing cases ...
}
return m, nil
}File: /packages/tui/internal/wallet/monitor.go
package wallet
import (
"context"
"time"
tea "github.com/charmbracelet/bubbletea/v2"
)
type BalanceMonitor struct {
provider *RoutstrProvider
program *tea.Program
interval time.Duration
done chan struct{}
}
func NewBalanceMonitor(provider *RoutstrProvider, program *tea.Program) *BalanceMonitor {
return &BalanceMonitor{
provider: provider,
program: program,
interval: 30 * time.Second,
done: make(chan struct{}),
}
}
func (bm *BalanceMonitor) Start(ctx context.Context) {
ticker := time.NewTicker(bm.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
balance, err := bm.provider.checkBalance(ctx)
if err == nil {
bm.program.Send(WalletBalanceUpdatedMsg{Balance: balance})
// Check for low balance
if balance < 1000 { // Less than 1000 sats
bm.program.Send(LowBalanceWarningMsg{Balance: balance})
}
}
case <-bm.done:
return
case <-ctx.Done():
return
}
}
}
func (bm *BalanceMonitor) Stop() {
close(bm.done)
}File: /packages/tui/internal/wallet/lightning.go
package wallet
import (
"fmt"
"net/http"
"encoding/json"
"bytes"
)
type InvoiceRequest struct {
Amount int64 `json:"amount"`
Description string `json:"description"`
}
type InvoiceResponse struct {
PaymentRequest string `json:"payment_request"`
PaymentHash string `json:"payment_hash"`
CheckingID string `json:"checking_id"`
}
func (cw *CashuWallet) CreateInvoice(amount int64, description string) (*InvoiceResponse, error) {
mintURL := cw.config.CurrentMintURL
reqBody := InvoiceRequest{
Amount: amount,
Description: description,
}
jsonBody, _ := json.Marshal(reqBody)
resp, err := http.Post(mintURL+"/v1/mint/quote/bolt11", "application/json", bytes.NewBuffer(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to create invoice: %w", err)
}
defer resp.Body.Close()
var result InvoiceResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to parse invoice response: %w", err)
}
return &result, nil
}File: /packages/tui/internal/components/dialog/topup.go
package dialog
import (
"fmt"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/mdp/qrterminal/v3"
)
type TopupDialog struct {
invoice string
qrCode string
amount int64
width int
height int
}
func NewTopupDialog(invoice string, amount int64) *TopupDialog {
var qrCode string
qrterminal.GenerateWithConfig(invoice, qrterminal.Config{
Level: qrterminal.M,
Writer: &qrCodeBuffer{},
BlackChar: qrterminal.BLACK,
WhiteChar: qrterminal.WHITE,
})
return &TopupDialog{
invoice: invoice,
qrCode: qrCode,
amount: amount,
}
}
func (d *TopupDialog) View() string {
title := fmt.Sprintf("Top up wallet with %d sats", d.amount)
content := lipgloss.JoinVertical(
lipgloss.Center,
title,
"",
"Scan QR code with your Lightning wallet:",
"",
d.qrCode,
"",
"Or copy this invoice:",
d.invoice,
"",
"Press 'q' to close",
)
return lipgloss.Place(d.width, d.height, lipgloss.Center, lipgloss.Center, content)
}File: /packages/tui/internal/app/app.go
Add low balance handling:
func (a *App) HandleLowBalance(balance int64) tea.Cmd {
return func() tea.Msg {
// Create Lightning invoice for top-up
wallet, err := wallet.NewCashuWallet()
if err != nil {
return ErrorMsg{Error: err}
}
suggestedAmount := int64(10000) // 10k sats
if balance < 1000 {
suggestedAmount = 5000 // 5k sats for very low balance
}
invoice, err := wallet.CreateInvoice(suggestedAmount, "OpenCode wallet top-up")
if err != nil {
return ErrorMsg{Error: err}
}
return ShowTopupDialogMsg{
Invoice: invoice.PaymentRequest,
Amount: suggestedAmount,
}
}
}File: /packages/tui/internal/wallet/refund.go
package wallet
func (rp *RoutstrProvider) RequestRefund(ctx context.Context) (string, error) {
req, _ := http.NewRequestWithContext(ctx, "POST", rp.baseURL+"/v1/wallet/refund", nil)
req.Header.Set("Authorization", "Bearer "+rp.currentKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result struct {
Token string `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
// Receive the refund token into our wallet
if result.Token != "" {
_, err = rp.wallet.ReceiveToken(ctx, result.Token)
if err != nil {
return "", fmt.Errorf("failed to receive refund: %w", err)
}
}
return result.Token, nil
}File: /packages/opencode/src/cli/cmd/wallet.ts
Add QR code support and enhanced UX:
export const WalletTopupCommand = cmd({
command: "topup [amount]",
describe: "top up wallet via Lightning",
builder: (yargs) =>
yargs.positional("amount", {
describe: "amount in sats",
type: "number",
default: 10000,
}),
async handler(args) {
const response = await fetch('http://localhost:8000/wallet/invoice', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: args.amount }),
});
const data = await response.json();
// Display QR code in terminal
console.log(`⚡ Lightning Invoice for ${args.amount} sats`);
console.log();
displayQRCode(data.payment_request);
console.log();
console.log("Invoice:", data.payment_request);
console.log();
console.log("Waiting for payment...");
// Poll for payment
await pollForPayment(data.checking_id);
console.log("✅ Payment received!");
},
})- Wallet balance displayed in status bar
- Automatic low balance detection and top-up prompts
- QR code generation for Lightning invoices
- Seamless refund handling
- Complete user experience with visual feedback
File: /packages/tui/internal/storage/schema.sql
CREATE TABLE IF NOT EXISTS wallet_state (
id INTEGER PRIMARY KEY,
balance INTEGER NOT NULL DEFAULT 0,
mint_url TEXT NOT NULL,
seed TEXT NOT NULL, -- Encrypted
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS routstr_sessions (
id TEXT PRIMARY KEY,
api_key TEXT NOT NULL,
base_url TEXT NOT NULL,
balance INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS transactions (
id TEXT PRIMARY KEY,
type TEXT NOT NULL, -- 'topup', 'spend', 'refund'
amount INTEGER NOT NULL,
description TEXT,
cashu_token TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);File: /packages/opencode/opencode.json
{
"providers": {
"routstr": {
"name": "Routstr",
"description": "Bitcoin Lightning AI payments",
"baseUrl": "https://api.routstr.com",
"mintUrl": "https://mint.minibits.cash/Bitcoin",
"models": ["anthropic/claude-sonnet-4", "meta-llama/llama-3.2-1b-instruct"],
"defaultModel": "anthropic/claude-sonnet-4"
}
}
}-
Insufficient Balance:
- Show current balance and required amount
- Offer immediate top-up with Lightning invoice
- Allow continuing with different model/provider
-
Token Validation Failures:
- Clear guidance on token format
- Link to supported wallet applications
- Fallback to manual entry
-
Network Connectivity:
- Offline mode detection
- Graceful degradation
- Retry mechanisms with exponential backoff
-
Wallet Corruption:
- Backup and recovery mechanisms
- Safe wallet reset options
- Data export capabilities
- Encrypt Cashu tokens at rest using system keyring
- Never log or expose tokens in plaintext
- Implement secure token rotation
- Always use HTTPS for Routstr communication
- Validate SSL certificates
- Implement request signing for sensitive operations
- Use BIP39 mnemonic seed generation
- Encrypt wallet database with user-derived key
- Implement wallet backup and recovery
- No personal information required
- Anonymous payment flows
- Optional Tor support for enhanced privacy
- Cashu wallet operations
- Routstr API integration
- Balance calculations and conversions
- Token validation and formatting
- End-to-end auth flow
- API request/response cycles
- Balance updates and monitoring
- Refund processing
- Fresh installation: No existing wallet, first-time setup
- Existing wallet: Resuming with existing balance
- Low balance: Handling insufficient funds scenarios
- Network issues: Offline/online transitions
- Multiple sessions: Concurrent usage patterns
- Maintain existing auth.json format
- Graceful fallback for non-Routstr providers
- Optional Routstr features (don't break existing workflows)
- Update CLI help text
- Add Routstr provider documentation
- Create wallet management guides
- Update troubleshooting sections
- Track wallet creation success rates
- Monitor balance usage patterns
- Log payment failures and retries
- Measure user engagement with Bitcoin payments
- Routstr appears in provider list
- Can authenticate with Cashu token
- Successfully make API requests
- Basic error handling works
- Internal wallet stores and manages tokens
- Automatic token provisioning works
- CLI wallet commands functional
- Balance tracking accurate
- Status bar shows wallet balance
- Lightning invoice generation works
- QR codes display properly
- Low balance prompts appear
- Refund mechanism functional
- End-to-end user experience smooth
This integration plan provides a comprehensive approach to adding Bitcoin Lightning payments to OpenCode through Routstr. The three-stage approach ensures incremental delivery of value while maintaining system stability and user experience quality.
The recommended Persistent Wallet Model (Approach A) provides the best balance of implementation complexity and user experience, leveraging existing OpenCode patterns while abstracting the underlying payment complexity.
Key benefits of this integration:
- Anonymous payments: No personal information or credit cards required
- Instant settlement: Immediate payment processing via Lightning Network
- Micropayment friendly: Pay only for what you use
- Self-sovereign: User controls their own funds and keys
- Privacy-focused: Bitcoin-native payment flows
The plan addresses the complex challenges of token lifecycle management, balance tracking, and user experience while maintaining OpenCode's existing architecture and usability standards.