asgayapedia

Connection Management Patterns

Purpose: Document battle-tested patterns for managing Electrum/Fulcrum connections on Android.

Status: Production-proven (August 9-21, 2026)
Context: These patterns were discovered through debugging production hangs and are critical for reliability.


⚠️ MAJOR UPDATE (Aug 20-21, 2026): v0.2 Hybrid Architecture

The WebSocket connection problem was solved architecturally, not with more workarounds.

Old (v0.1): Covenant operations (claim/refund/abort) ran entirely in the WebView — including the Fulcrum WebSocket connection (port 60003) and broadcast. WebView JS timers pause when the screen is off, so timeouts never fired → connections hung and accumulated (4+ ESTABLISHED).

New (v0.2 hybrid): Kotlin owns ALL network operations. The WebView does compute only (build + sign) and returns hex via onTransactionBuilt(). Kotlin broadcasts over native TCP (port 60001) with OS-level timeouts.

What this means for THIS document:

Current connection usage (v0.2):

Operation Port Protocol Purpose
Balance query 60001 TCP ElectrumClient.getBalance()
UTXO fetch (CREATE) 60001 TCP ElectrumClient.getUTXOsForAddress()
UTXO fetch (REFUND/CLAIM/ABORT) 60003 WebSocket contract.getUtxos() (brief)
Broadcast (all ops) 60001 TCP ElectrumClient.broadcast()

This is why multi-device works now: no long-lived WebSocket in the WebView, no JS-timer dependency, connections open/close per operation in Kotlin.


Overview

Asgaya uses two types of connections to Fulcrum (Electrum server):

  1. TCP connections (port 60001) - ElectrumClient for balance queries, UTXO fetches, and all broadcasts (v0.2)
  2. WebSocket connections (port 60003/60004) - Only the brief covenant UTXO fetch in REFUND/CLAIM/ABORT (v0.2); was broadcast in v0.1

The challenge: Android doesn’t release connections instantly. Poor connection management causes:

These patterns prevent those issues.


Pattern 1: TCP Connection Cooldown (5 Seconds)

Discovered: August 9-10, 2026
Context: Balance queries hanging subsequent WebSocket operations

The Problem

// User workflow:
1. Tap "🔄 Update Status"  TCP query (port 60001) to check balance
2. Query completes  disconnect()
3. Tap "💰 Claim"  WebSocket connection (port 60003) attempts
4.  HANGS - Android OS still holding TCP socket!

Why it hangs:

Note: This behavior is specific to the current implementation and test environment (Moto G06, Pixel 6a running AsgayaHusk), not necessarily universal Android OS behavior.

The Solution

Add 5-second delay after ALL balance queries:

// In RemittanceAdapter.kt (or wherever balance queries happen)
holder.checkBalanceButton.setOnClickListener {
    CoroutineScope(Dispatchers.IO).launch {
        try {
            // Query balance via TCP
            val balance = electrumClient.getBalance(
                address = covenantAddress,
                host = "192.168.1.100",
                port = 60001  // TCP port
            )
            
            withContext(Dispatchers.Main) {
                // Update UI with balance
                holder.covenantStatus.text = if (balance > 0) "✅ funded" else "✅ Claimed"
                
                // ⚠️ CRITICAL: 5-second TCP cooldown
                // Wait for Android OS to fully release TCP connection
                // before allowing WebSocket operations (claim/refund)
                Log.d(TAG, "⏳ Waiting 5s for TCP connection cleanup...")
                delay(5000)  // Tested: 2s not enough, 5s works reliably
                Log.d(TAG, "✅ TCP connection cleanup complete")
            }
        } catch (e: Exception) {
            // Handle error
        }
    }
}

Why 5 Seconds?

Testing results (August 9-10):

Trade-off accepted:

Phase 0 Workaround Status

⚠️ This is a Phase 0 workaround, not a long-term solution.

For single-device testing: Acceptable UX (user can wait 5 seconds)

At scale: UX concern - user tapping “Update Status” → “Claim” experiences visible pause

Future improvements to explore:

  1. Connection pooling - Maintain persistent connections instead of connect/disconnect per operation
  2. Connection reuse - Share ElectrumClient instances across queries
  3. Separate connection pools - Isolate TCP (balance) from WebSocket (transactions)
  4. Async UI feedback - Show “Preparing claim…” during cooldown instead of silent pause
  5. Subscription-based updates - Eliminate manual queries entirely (see Pattern 4 below)

For now: 5-second delay is documented, tested, and prevents production hangs. This is acceptable for Phase 0.


Pattern 2: WebSocket Cleanup (Finally Blocks)

Discovered: August 9, 2026
Context: First operation works, second hangs (zombie connections)

The Problem

// BEFORE (Bug)
async function sendBch() {
    const electrum = new ElectrumClient(...);
    await electrum.connect();
    
    // ... do work ...
    
    await electrum.disconnect();  // ❌ Only called if work succeeds!
    return txid;
}

// What happens on error:
// 1. Connection opens ✅
// 2. Error occurs (timeout, network issue, validation failure)
// 3. Function throws exception
// 4. disconnect() never called! ❌
// 5. WebSocket remains open (zombie connection)
// 6. Next operation tries to connect → hangs (OS at connection limit)

Pattern: First operation succeeds → disconnect() called ✅
Next operation hangs → because previous error left zombie connection ❌

The Solution

Always disconnect in finally block:

// AFTER (Correct)
async function sendBch() {
    let electrum = null;
    
    try {
        electrum = new ElectrumClient(...);
        await electrum.connect();
        
        // ... do work ...
        
        return txid;
        
    } catch (error) {
        log(`❌ Error: ${error.message}`);
        throw error;
        
    } finally {
        // ✅ Always disconnect, whether success or failure
        if (electrum) {
            await electrum.disconnect();
            log('🔌 Disconnected from Fulcrum');
        }
    }
}

Key insight: finally runs whether function succeeds, throws, or returns early.

Where to Apply

All WebSocket operations need finally blocks:

sendBch() - Covenant funding (covenant-bridge.html line ~650)
claimCovenant() - Recipient claim (covenant-bridge.html line ~240)
refundCovenant() - Sender refund (covenant-bridge.html line ~440)

TCP operations (ElectrumClient):

Testing the Fix

Before fix:

Operation 1: ✅ Success (disconnect called)
Operation 2: ✅ Success (disconnect called)
Operation 3: ❌ Network timeout during work
              → No disconnect → Zombie connection
Operation 4: ❌ HANGS (connection limit reached)

After fix:

Operation 1: ✅ Success → finally → disconnect
Operation 2: ✅ Success → finally → disconnect  
Operation 3: ❌ Network timeout → finally → disconnect anyway!
Operation 4: ✅ Success (no zombies left!)

Status: Fixed in all covenant operations (August 9, 2026)


Pattern 3: Fulcrum Port Configuration

Discovered: August 9, 2026
Context: Port and protocol selection for Electrum/Fulcrum connections

Raspberry Pi Testnet Node Configuration

Confirmed working setup (Raspberry Pi testnet node running Fulcrum + Bitcoin Core):

# /home/suso/fulcrum-testnet.conf

# TCP (Electrum protocol via standard socket)
tcp = 0.0.0.0:60001

# WebSocket (Electrum protocol via WebSocket, no SSL)
ws = 0.0.0.0:60003

# WebSocket Secure (Electrum protocol via WebSocket with SSL)
wss = 0.0.0.0:60004
cert = /home/suso/fulcrum-certs/fulcrum-cert.pem
key = /home/suso/fulcrum-certs/fulcrum-key.pem

Verification (on the testnet node):

ss -tlnp | grep -E "60001|60003|60004"
# Output:
# LISTEN 0.0.0.0:60001  (Fulcrum - TCP)
# LISTEN 0.0.0.0:60003  (Fulcrum - WebSocket)
# LISTEN 0.0.0.0:60004  (Fulcrum - WebSocket Secure)

Port Usage in Asgaya

ElectrumClient (Balance Queries):

// Uses TCP (port 60001)
val balance = electrumClient.getBalance(
    address = covenantAddress,
    host = "192.168.1.100",
    port = 60001  // TCP - no WebSocket, no SSL
)

CovenantWebView (JavaScript - Covenant Operations):

// v0.2 HYBRID: WebSocket is used ONLY for the brief UTXO fetch
// (contract.getUtxos() in REFUND/CLAIM/ABORT). Broadcast happens in Kotlin (TCP 60001).
const useSSL = (fulcrumPort === 60004 || fulcrumPort === 50003 || fulcrumPort === 50004);
// Port 60003 → useSSL = false ✅

const socket = new ElectrumWebSocket(
    "192.168.1.100",
    60003,  // WebSocket port — UTXO fetch only
    false   // No SSL
);

Kotlin → JavaScript bridge (RemittanceActivity, ReviewSendActivity):

val txid = covenantWebView.claimCovenant(
    // ... params ...
    fulcrumHost = "192.168.1.100",
    fulcrumPort = 60003  // WebSocket (ws://), not TCP, not WSS
)

SSL Detection Logic

In covenant-bridge.html (JavaScript):

// SSL detection based on standard Electrum ports + our custom ports
const useSSL = (
    fulcrumPort === 60004 ||  // Our WSS port
    fulcrumPort === 50003 ||  // Standard Electrum SSL
    fulcrumPort === 50004     // Standard Electrum WSS
);

// Port 60001 → TCP (no WebSocket, no SSL)
// Port 60003 → WebSocket (no SSL) ✅ We use this
// Port 60004 → WebSocket Secure (SSL)

Port and Protocol Reference

Port configuration errors have been the source of multiple production hangs. When troubleshooting connection issues, verify the protocol matches the port before investigating other causes.

Port Assignment Table:

Port Protocol Used By SSL Purpose
60001 TCP ElectrumClient No Balance queries, UTXO fetches, all broadcasts (v0.2)
60003 WebSocket CovenantWebView No Brief covenant UTXO fetch (REFUND/CLAIM/ABORT)
60004 WebSocket Secure Reserved Yes Future encrypted operations

Common Configuration Errors

Error 1: Protocol Mismatch - TCP port for WebSocket

fulcrumPort = 60001  // ❌ Wrong - This is TCP, not WebSocket
// Result: WebSocket connection hangs (protocol mismatch)

Error 2: Incorrect SSL Detection

const useSSL = (fulcrumPort === 60003 || ...);  // ❌ Wrong
// Result: SSL handshake fails (port 60003 is plain ws://, not wss://)

Error 3: Protocol Mismatch - WebSocket port for TCP client

electrumClient.getBalance(..., port = 60003)  // ❌ Wrong
// Result: ElectrumClient expects TCP, not WebSocket

Troubleshooting Connection Issues

Symptoms:

Diagnostic steps:

  1. Verify port number - Confirm it’s 60001, 60003, or 60004
  2. Match protocol to port - Use the table above to verify correct protocol
  3. Check SSL detection - Port 60003 should have useSSL = false
  4. Confirm Fulcrum configuration - Verify ports are listening on the testnet node

Verification command (on testnet node):

ss -tlnp | grep -E "60001|60003|60004"
# Expected output:
# LISTEN 0.0.0.0:60001  (Fulcrum - TCP)
# LISTEN 0.0.0.0:60003  (Fulcrum - WebSocket)
# LISTEN 0.0.0.0:60004  (Fulcrum - WebSocket Secure)

Pattern 4: Manual Updates vs Subscriptions

Context: When to poll manually vs subscribe for real-time notifications

Current Pattern: Manual Updates (Phase 0)

Implementation:

Benefits:

Trade-offs:

Alternative Pattern: Electrum Subscriptions (Future)

How it works:

// 1. Subscribe to address (converted to scripthash)
const scripthash = addressToScripthash(covenantAddress);
await electrum.request('blockchain.scripthash.subscribe', scripthash);

// 2. Server immediately returns current status hash
// Response: "a1b2c3..." (hash of current tx history)

// 3. Server pushes notification when status changes
// Notification: { scripthash: "...", status: "d4e5f6..." }
// Triggered by: new tx broadcast, confirmation, UTXO spent

// 4. Query for new transactions
const txs = await electrum.request('blockchain.scripthash.get_history', scripthash);

Benefits:

Trade-offs:

When to Use Each Pattern

Manual Updates (Current - Phase 0):

Use when:

Subscriptions (Future - Post Phase 0):

Use when:

Key insight: Nostr coordination makes subscriptions redundant in MOST cases!

Nostr vs Electrum Subscriptions

Nostr handles most coordination:

Sender → Seller (covenant params):
  ✅ Nostr DM → Seller gets notification

Sender → Recipient (covenant params):
  ✅ Nostr DM → Recipient gets notification + knows it's ready to claim

Recipient claims → Seller (buffer returned):
  ✅ Seller bot already monitoring → No notification needed

The ONE exception - Seller funding notification:

1. Sender creates covenant → Nostr DM to seller (params)
2. Sender pays seller (cash/bank - off-chain)
3. ⏳ SELLER BOT FUNDS COVENANT (async, timing unknown)
4. 🔔 SENDER NEEDS NOTIFICATION ← Electrum subscription fits here!
   - Blockchain is source of truth (funded = UTXO exists)
   - Seller bot might not have Nostr integration
   - No trust required (can't fake blockchain state)
5. Sender → Recipient (Nostr DM)
6. Recipient claims (seller bot monitors, no notification needed)

Why Electrum subscription for step #4:

Implementation approach:

// In RemittanceActivity (after creating covenant)
if (fundingModel == FundingModel.SELLER_FUNDED) {
    // Subscribe to covenant address
    subscribeToCovenantFunding(covenant.address) { funded ->
        if (funded) {
            showNotification("✅ Seller funded covenant! Ready to share with recipient")
        }
    }
}

The SECOND exception - Seller resolution notification:

1. Seller funds covenant (locks BCH)
2. ⏳ COVENANT RESOLVES (claim, refund, or abort - timing unknown)
3. 🔔 SELLER NEEDS NOTIFICATION ← Electrum subscription fits here!
   - Blockchain is source of truth (UTXO spent = resolved)
   - Seller needs to reconcile capital (BCH released, buffer settled)
   - Seller can re-deploy capital immediately
   - Abort scenario: Seller can buy the dip with received fiat
4. Seller updates accounting, re-enters market

Why Electrum subscription for covenant resolution:

Implementation approach:

// Seller bot monitoring (after funding covenant)
subscribeToCovenantResolution(covenant.address) { resolution ->
    when (resolution.spendingPath) {
        "claim" -> {
            // Recipient claimed successfully
            // Buffer returned to seller (funder principle)
            log("✅ Covenant claimed - buffer returned")
            recycleCapital(covenant.bufferAmount)
        }
        "refund" -> {
            // Payment returned to sender; buffer returned to seller (funder principle)
            // Seller gets buffer back (same BCH amount, possibly different value)
            log("⚠️ Covenant refunded - buffer returned")
            recycleCapital(covenant.bufferAmount)
        }
        "abort" -> {
            // Buffer consumed by price drop; BCH returned to sender; seller keeps fiat
            // Seller gets NOTHING from covenant (all BCH → sender)
            // But seller keeps fiat already received via Bizum
            log("⚠️ Covenant aborted - buffer consumed, keeping fiat only")
            
            // Optional: Buy-the-dip opportunity
            // Seller has fiat, can buy BCH at new (lower) price
            if (autoBuyEnabled) {
                considerBuyingDip(covenant.fiatReceived)
            }
        }
    }
}

Note on buy-the-dip behavior (abort scenario only):

Recommendation: Manual First, Automate Strategically

Phase 0 (Current):

Phase 1 (After merchant flow):

Phase 2 (If needed):

Don’t automate assumptions - automate proven pain points! 🎯


Pattern 5: Connection Lifecycle Management

Best practices for connection lifecycle:

Short-Lived Connections (Current - Phase 0)

Pattern:

suspend fun doOperation(): Result {
    var electrum: ElectrumClient? = null
    try {
        // 1. Create
        electrum = ElectrumClient(...)
        
        // 2. Connect
        electrum.connect()
        
        // 3. Do work
        val result = electrum.doSomething()
        
        // 4. Return
        return Result.success(result)
        
    } catch (e: Exception) {
        return Result.failure(e)
        
    } finally {
        // 5. Always disconnect
        electrum?.disconnect()
    }
}

Use when:

Benefits:

Long-Lived Connections (Future - Subscriptions)

Pattern:

class CovenantMonitorService : Service() {
    private var electrum: ElectrumWebSocket? = null
    private var reconnectJob: Job? = null
    
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        lifecycleScope.launch {
            connectWithRetry()
        }
        return START_STICKY
    }
    
    private suspend fun connectWithRetry() {
        while (isActive) {
            try {
                electrum = ElectrumWebSocket(...)
                electrum.connect()
                
                // Subscribe to covenants
                subscribeToActiveCovenants()
                
                // Keep alive with heartbeat
                maintainConnection()
                
            } catch (e: Exception) {
                Log.e(TAG, "Connection failed, retrying in 30s", e)
                delay(30_000)
            }
        }
    }
    
    override fun onDestroy() {
        runBlocking {
            electrum?.disconnect()
        }
        super.onDestroy()
    }
}

Use when:

Challenges:

For Phase 0: Stick with short-lived connections!


Testing Checklist

Before deploying connection management changes:

Verification commands (on testnet node):

# Check Fulcrum is listening on correct ports
ss -tlnp | grep -E "60001|60003|60004"

# Monitor active connections during operation
watch -n 1 'ss -tn | grep -E "60001|60003|60004"'

Known Issues & Workarounds

Issue 1: 5-Second Cooldown UX Pause

Problem: User waits 5 seconds after balance check before claim button responds

Workaround (Phase 0): Document behavior, accept UX trade-off for reliability

Future fix: Connection pooling or subscription-based updates


Issue 2: WebSocket Hangs on Reconnect

Problem: If WebSocket disconnects unexpectedly, reconnect might hang

Workaround: Timeout + retry logic in JavaScript:

const connectWithTimeout = Promise.race([
    electrum.connect(),
    new Promise((_, reject) => 
        setTimeout(() => reject(new Error('Timeout')), 10000)
    )
]);

Status: Implemented in covenant-bridge.html (all operations)


Issue 3: Connection Pool Exhaustion

Problem: Too many rapid operations exhaust Android socket limit

Workaround: 5-second TCP cooldown + finally block cleanup prevents this

Future fix: Connection pooling (reuse connections instead of create/destroy)


Issue 4: WebSocket Connections Hang Silently (No Exception)

Discovered: August 17, 2026 (stuck “Sending…” UI bug)
Status: ✅ RESOLVED architecturally Aug 20 (v0.2 hybrid) — see below

Problem: A WebSocket connection can hang indefinitely without throwing an exception. No timeout, no error, just an infinite wait — the UI stays in “Sending…” forever even though the transaction eventually succeeds on-chain.

Root cause: WebView JavaScript timers pause when the screen turns off or the app is backgrounded. The JS timeout wrappers never fire, so a hung connection stays hung. Connections accumulated (4+ ESTABLISHED observed), which also explained why the second covenant always failed.

v0.1 workaround (Aug 17): Wrap all async operations in withTimeout() (Kotlin):

// Fetch oracle pubkey: 10-second timeout
suspend fun fetchOraclePubkey() = withTimeout(10_000) { ... }

// Create covenant: 10-second timeout
suspend fun createCovenant() = withTimeout(10_000) { ... }

// Send/broadcast: 30-second timeout (longest operation)
suspend fun sendBch() = withTimeout(30_000) { ... }

Key detail: distinguish TimeoutCancellationException (a real error — show the user) from CancellationException (expected when the user navigates away — handle silently).

✅ REAL FIX (Aug 20): withTimeout was a band-aid. The actual fix was the v0.2 hybrid architecture — move broadcast out of the WebView entirely. Now:

Why send() was the trap (Aug 20 discovery): CashScript’s TransactionBuilder.send() does build → sendRawTransaction → getTxDetails(), and getTxDetails() polls getRawTransaction() for up to 10 minutes with a dummy/empty txid. This is why a mock provider approach hung forever. The correct API is build() — returns signed hex, fully local, no network. Never call .send() in the WebView.

Still relevant: withTimeout() remains good practice for the brief WebSocket UTXO fetch and for Kotlin network calls (defense in depth). But it is no longer the primary reliability mechanism.


Issue 5: lifecycleScope Cancels Transactions Mid-Broadcast

Discovered: August 17, 2026
Status: ✅ RESOLVED Aug 17-20 via ViewModel migration (RS083)

Problem: lifecycleScope cancels all coroutines when the activity is destroyed (navigation, screen rotation, background kill). A transaction broadcasting on-chain when the activity dies succeeds on-chain but the UI never updates and the database never records it.

v0.1 workaround (Aug 17): Faster timeouts reduce the cancellation window; onResume() detects and resets a stuck “Sending…” button; silent cancellation handling.

✅ REAL FIX (Aug 17-18, RS083): Migrated transaction logic to a SendViewModel using viewModelScope — survives activity destruction. Combined with:

Key insight: Android kills apps aggressively in the background (PID observed changing 12286 → 14057 mid-transaction). Transaction state must persist to the database, not just memory. See state-management.md for the schema and RS083 research for the full pattern study.


Summary

Battle-tested patterns (August 9-21, 2026):

  1. 5-second TCP cooldown after balance queries (prevents WebSocket hangs)
  2. Finally blocks for WebSocket cleanup (prevents zombie connections)
  3. Port configuration - 60001 TCP, 60003 WS (no SSL), 60004 WSS
  4. Manual updates in Phase 0 (subscriptions are future enhancement)
  5. Short-lived connections (create → use → disconnect in finally)

v0.2 architecture (Aug 20-21) supersedes the WebSocket-centric model:

Key insight: Connection management is architecture, not implementation detail. The v0.2 hybrid (Kotlin owns the network) eliminated the entire class of WebView-connection bugs rather than patching symptoms.


Status: Production-proven
Last Updated: 2026-08-21 (v0.2 hybrid architecture reframe)
Evidence: Successful covenant operations with 0% hangs after implementing these patterns; multi-device stress test passed with hybrid (Aug 20-21)


Implementation:

Design: