asgayapedia

State Management Component

Purpose: Track covenant lifecycle, cache blockchain data, manage local persistence

Complexity: Medium - Database design + blockchain state reconciliation

Status Update (August 21, 2026):

What this document describes: Full state management architecture for MVP (all five gears integrated)

What Phase 0 implemented: Wallet storage + pending transaction persistence (see Pending Transactions (Phase 0) below)


⚠️ Originally Planned for Phase 0 - Implemented Partially

State Management was envisioned as complete data persistence layer for Asgaya:

Phase 0 Reality:

  1. Wallet storage - Room database with WalletEntity (name, WIF, address, balance, type)
  2. WalletManager - Reactive updates via LiveData/Flow
  3. Pending transaction persistence (RS083, Aug 17-18) - pending_transactions table + SendViewModel so transactions survive activity death (BROADCAST → MEMPOOL → CONFIRMED)
  4. Covenant tracking - Not needed yet (self-funded sender, no seller coordination)
  5. Bulletin board cache - No bulletin board in Phase 0
  6. Sync strategies - Manual updates only (no background sync)

Key Design Principle (Still Valid):

“Blockchain is always right. Local state is cache + UI optimizations.”

Phase 1+ Sync Strategy: Event-driven reconciliation (Electrum notifications → local DB updates)

This document: Full MVP design. Phase 0 implemented wallet storage + pending transaction persistence (RS083).


Overview

State Management bridges the gap between blockchain (source of truth) and app (needs fast UI):

Key principle: Blockchain is always right. Local state is cache + UI optimizations.


What to Store Locally

Data Categories

1. My Covenants (Created or Funded)

Table: covenants
- covenant_id (TEXT, PRIMARY KEY)
- role (TEXT: "sender" or "seller")
- recipient_cash_account (TEXT)
- amount (REAL)
- buffer (REAL)
- total_bch (REAL)
- status (TEXT: "created", "funded", "claimed", "expired", "aborted")
- created_at (INTEGER, Unix timestamp)
- expires_at (INTEGER)
- funded_tx_id (TEXT, nullable)
- claimed_tx_id (TEXT, nullable)

Why store? Fast lookup (“Show me all my active covenants”), offline access


2. My Listings

Table: listings
- listing_id (TEXT, PRIMARY KEY)
- type (TEXT: "buy" or "sell")
- currency (TEXT)
- amount_min (REAL)
- amount_max (REAL)
- payment_methods (TEXT, JSON array)
- created_at (INTEGER)
- expires_at (INTEGER)
- active (BOOLEAN)

Why store? Know which listings I have active, edit/delist quickly


3. Cached Bulletin Board Listings

Table: cached_listings
- listing_id (TEXT, PRIMARY KEY)
- seller_cash_account (TEXT)
- currency (TEXT)
- amount_min (REAL)
- amount_max (REAL)
- payment_methods (TEXT, JSON)
- rating (REAL, nullable)
- cached_at (INTEGER, timestamp)
- ttl (INTEGER, seconds)

Why store? Avoid querying Electrum every time user opens app (5-minute cache)


4. Cash Account Cache

Table: cash_account_cache
- cash_account (TEXT, PRIMARY KEY, e.g., "Elena#142")
- address (TEXT, BCH address)
- resolved_at (INTEGER, timestamp)
- ttl (INTEGER, 86400 seconds = 24 hours)

Why store? Cash Account resolution is slow (Electrum query), cache aggressively


5. Wallet Metadata

Table: wallet_metadata
- key (TEXT, PRIMARY KEY)
- value (TEXT)

Examples:
- "last_used_index" → "5" (HD wallet derivation)
- "cash_account" → "Isabel#142"
- "nostr_pubkey" → "npub1..."

Why store? Small configuration values that don’t fit elsewhere


6. Notification History (Auto-Funding Log)

Table: notification_history
- id (INTEGER, PRIMARY KEY AUTOINCREMENT)
- notification_text (TEXT)
- parsed_amount (REAL, nullable)
- parsed_reference (TEXT, nullable)
- covenant_id (TEXT, nullable)
- action_taken (TEXT: "funded", "no_match", "error")
- timestamp (INTEGER)

Why store? Debug auto-funding issues, show user history (“You funded 3 covenants today”)


What NOT to Store

❌ Don’t store:

Why? Security (keys), privacy (other users), performance (too much data)


Pending Transactions (Phase 0, RS083)

Implemented: Aug 17-18, 2026 (RS083 transaction broadcast patterns, studied from Selene/Paytaca)

Purpose: Persist in-flight transactions so they survive activity death, app backgrounding, and device restarts. Combined with SendViewModel (uses viewModelScope, not lifecycleScope), this solved the “transaction broadcast but UI stuck on Sending” class of bugs.

Schema

Table: pending_transactions
- id (INTEGER, PRIMARY KEY AUTOINCREMENT)
- txid (TEXT, UNIQUE)
- tx_hex (TEXT)
- status (TEXT: BROADCAST / MEMPOOL / CONFIRMED / REFUNDED / ABORTED / CLAIMED)
- covenant_address (TEXT)
- remittance_id (INTEGER, nullable — FK to remittances table)
- created_at (INTEGER, timestamp)
- confirmed_at (INTEGER, nullable)
- block_height (INTEGER, nullable)
- confirmations (INTEGER, nullable)

Status Lifecycle

BROADCAST → MEMPOOL → CONFIRMED
    └── (dropped from mempool → rebroadcast on resume)

How It Works (SendViewModel, viewModelScope)

  1. Broadcast → insert row with status BROADCAST
  2. Background confirmation monitoring — a viewModelScope coroutine polls Electrum every 60s (up to 1 hour), updating MEMPOOL then CONFIRMED with block height/confirmations
  3. Rebroadcast on resumeonResume() checks for non-confirmed pending transactions and rebroadcasts if dropped
  4. Navigate-on-success — on success, navigate away WITHOUT resetting the “sending” flag (Selene pattern); transaction state is DB-backed, not UI-backed

Key Insight (RS083)

Android kills apps aggressively in the background (PID observed changing 12286 → 14057 mid-transaction). Transaction state must persist to the database, not just memory. viewModelScope survives activity destruction where lifecycleScope does not.

Full Pattern Study

See the research doc: docs/research/RS083_transaction_broadcast_ui_patterns.md (mirrored from knowledge/research/).


Database Technology

Android: Room (SQLite)

Why Room?

Example entity:

@Entity(tableName = "covenants")
data class Covenant(
    @PrimaryKey val covenant_id: String,
    val role: String,  // "sender" or "seller"
    val recipient_cash_account: String,
    val amount: Double,
    val buffer: Double,
    val total_bch: Double,
    val status: String,  // "created", "funded", "claimed", "expired", "aborted"
    val created_at: Long,
    val expires_at: Long,
    val funded_tx_id: String?,
    val claimed_tx_id: String?
)

@Dao
interface CovenantDao {
    @Query("SELECT * FROM covenants WHERE status = 'funded' ORDER BY created_at DESC")
    fun getActiveCovenants(): LiveData<List<Covenant>>
    
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertCovenant(covenant: Covenant)
    
    @Query("UPDATE covenants SET status = :status WHERE covenant_id = :id")
    fun updateCovenantStatus(id: String, status: String)
}

iOS: Core Data (SQLite)

Why Core Data?

Alternative: SQLite.swift (lighter weight, less magic)


Web/Desktop: IndexedDB

Why IndexedDB?

Alternative: localStorage (simpler, but synchronous and small)


Covenant Lifecycle Tracking

States

Created → Funded → Claimed (success path)
       ↘ Expired (timeout)
       ↘ Aborted (price drop)

State Transitions

Pseudocode:

function trackCovenantState(covenant_id):
  covenant = getCovenantFromDB(covenant_id)
  
  // Query blockchain for current state
  blockchain_state = electrumQuery("blockchain.covenant.get_status", {
    covenant_id: covenant_id
  })
  
  // Reconcile local state with blockchain
  if blockchain_state.funded and covenant.status == "created":
    // State changed: created → funded
    updateCovenantStatus(covenant_id, "funded")
    showNotification("Covenant funded: " + covenant.recipient_cash_account)
  
  else if blockchain_state.claimed and covenant.status == "funded":
    // State changed: funded → claimed
    updateCovenantStatus(covenant_id, "claimed")
    showNotification("Covenant claimed by " + covenant.recipient_cash_account)
  
  else if blockchain_state.expired and covenant.status == "funded":
    // State changed: funded → expired
    updateCovenantStatus(covenant_id, "expired")
    showNotification("Covenant expired, funds returned")
  
  else if blockchain_state.aborted:
    // Price dropped >7%, covenant aborted
    updateCovenantStatus(covenant_id, "aborted")
    showNotification("Covenant aborted (price drop)")

Event-Driven Updates

Problem: Polling blockchain every second is wasteful (battery, bandwidth)

Solution: Electrum subscriptions (push notifications)

Pseudocode:

function subscribeToCovenantUpdates(covenant_id):
  electrum.subscribe("covenant.status_changed", {
    covenant_id: covenant_id,
    callback: (new_status) => {
      // Electrum pushes update when covenant state changes
      updateCovenantStatus(covenant_id, new_status)
      refreshUI()
    }
  })

Electrum WebSocket message:

{
  "method": "covenant.status_changed",
  "params": {
    "covenant_id": "abc123...",
    "new_status": "funded",
    "tx_id": "def456..."
  }
}

Benefit: Near-instant updates (no polling), low battery usage


Cache Invalidation

Bulletin Board Cache

TTL: 5 minutes (balances freshness vs performance)

Pseudocode:

function getCachedListings(currency):
  cached = db.query("SELECT * FROM cached_listings WHERE currency = ? AND cached_at + ttl > ?", 
                    currency, now())
  
  if cached.length > 0:
    return cached  // Cache hit
  
  // Cache miss - query Electrum
  fresh_listings = queryElectrumListings(currency)
  
  // Store in cache
  for listing in fresh_listings:
    db.insert("cached_listings", {
      ...listing,
      cached_at: now(),
      ttl: 300  // 5 minutes
    })
  
  return fresh_listings

Manual refresh:

function refreshBulletinBoard():
  db.execute("DELETE FROM cached_listings")  // Clear cache
  queryElectrumListings(currency)  // Re-fetch

Cash Account Cache

TTL: 24 hours (Cash Accounts rarely change)

Pseudocode:

function resolveCashAccount(cash_account):
  cached = db.query("SELECT address FROM cash_account_cache WHERE cash_account = ? AND resolved_at + ttl > ?",
                    cash_account, now())
  
  if cached:
    return cached.address  // Cache hit
  
  // Cache miss - query Electrum
  address = electrumQuery("blockchain.cash_account.resolve", {name: cash_account})
  
  // Store in cache
  db.insert("cash_account_cache", {
    cash_account: cash_account,
    address: address,
    resolved_at: now(),
    ttl: 86400  // 24 hours
  })
  
  return address

Invalidation trigger: If Cash Account resolution fails, clear cache and retry


Blockchain Reconciliation

Problem

Local state can drift from blockchain:


Solution: Periodic Sync

Pseudocode:

function reconcileCovenants():
  local_covenants = db.query("SELECT * FROM covenants WHERE status != 'claimed' AND status != 'expired'")
  
  for covenant in local_covenants:
    blockchain_state = electrumQuery("blockchain.covenant.get_status", {
      covenant_id: covenant.covenant_id
    })
    
    if blockchain_state.status != covenant.status:
      log("Reconciling covenant " + covenant.covenant_id + ": " + covenant.status + " → " + blockchain_state.status)
      updateCovenantStatus(covenant.covenant_id, blockchain_state.status)

When to run:

Performance: Only query covenants that user created/funded (small set)


Offline Support

Queued Actions

Problem: User creates covenant while offline

Solution: Queue for broadcast when connectivity returns

Pseudocode:

Table: queued_actions
- id (INTEGER, PRIMARY KEY AUTOINCREMENT)
- action_type (TEXT: "create_covenant", "fund_covenant", "send_nostr_dm")
- payload (TEXT, JSON)
- created_at (INTEGER)
- retry_count (INTEGER)

function queueAction(action_type, payload):
  db.insert("queued_actions", {
    action_type: action_type,
    payload: json_encode(payload),
    created_at: now(),
    retry_count: 0
  })
  
  showNotification("Action queued (offline). Will process when online.")

function processQueuedActions():
  if not isOnline():
    return  // Still offline
  
  queued = db.query("SELECT * FROM queued_actions ORDER BY created_at ASC")
  
  for action in queued:
    try:
      if action.action_type == "create_covenant":
        createCovenant(json_decode(action.payload))
      else if action.action_type == "fund_covenant":
        fundCovenant(json_decode(action.payload))
      else if action.action_type == "send_nostr_dm":
        sendNostrDM(json_decode(action.payload))
      
      // Success - remove from queue
      db.delete("queued_actions", action.id)
    
    catch Error:
      // Retry later
      db.update("queued_actions", {retry_count: action.retry_count + 1}, action.id)
      
      if action.retry_count > 3:
        // Too many failures - notify user
        showNotification("Failed to process queued action. Manual intervention needed.")
        db.delete("queued_actions", action.id)

Trigger: Run processQueuedActions() when connectivity returns


Offline Browsing

Bulletin board cache: Available offline (stale but usable)

function getBulletinBoardListings(currency):
  if isOnline():
    return getCachedListings(currency)  // Might refresh cache
  else:
    // Offline - use stale cache
    cached = db.query("SELECT * FROM cached_listings WHERE currency = ?", currency)
    showWarning("Using cached listings (offline)")
    return cached

Covenant state: Last known state (might be outdated)

if not isOnline():
  showWarning("Offline. Covenant states may be outdated. Reconnect to sync.")

Database Migrations

Schema Changes

Problem: App updates might need new database columns/tables

Solution: Migration scripts

Example (Room):

@Database(entities = [Covenant::class, Listing::class], version = 2)
abstract class AppDatabase : RoomDatabase() {
    companion object {
        val MIGRATION_1_2 = object : Migration(1, 2) {
            override fun migrate(database: SupportSQLiteDatabase) {
                // Add new column: funded_tx_id
                database.execSQL("ALTER TABLE covenants ADD COLUMN funded_tx_id TEXT")
            }
        }
    }
}

val db = Room.databaseBuilder(context, AppDatabase::class.java, "asgaya.db")
    .addMigrations(MIGRATION_1_2)
    .build()

Testing: Always test migration on old database (don’t lose user data)


Backup & Restore

What to Backup

Seed phrase: User’s responsibility (write on paper)

Database: Optional (all state can be reconstructed from blockchain)


Restore Process

Scenario: User loses phone, restores on new device with seed phrase

Steps:

  1. User enters 12-word seed phrase
  2. App derives keys (BCH, Nostr, Cash Account)
  3. Query blockchain for user’s covenants:
    covenants = electrumQuery("blockchain.covenant.list_by_sender", {
      sender_address: my_address
    })
    
  4. Populate local database with found covenants
  5. Query bulletin board for my listings (if any)
  6. Done - app state restored

What’s lost: Notification history, cached bulletin board (not critical)

What’s preserved: All covenants, listings, wallet balance (blockchain-backed)


Error Handling

Database Corruption

try:
  db.query("SELECT * FROM covenants")
catch DatabaseCorruption:
  log_error("Database corrupted")
  show_error("Database corrupted. Restore from seed phrase?")
  
  if user_confirms:
    db.delete_database()
    restore_from_seed_phrase()

Sync Conflicts

Problem: Local state says “created”, blockchain says “funded”

Resolution: Blockchain wins (always)

if local_state != blockchain_state:
  log_warning("Sync conflict: " + covenant_id + " local=" + local_state + " blockchain=" + blockchain_state)
  updateCovenantStatus(covenant_id, blockchain_state)  // Override local

Missing Covenants

Problem: User created covenant on Device A, opens app on Device B

Solution: Query blockchain on startup (find all covenants for my address)

function syncMissingCovenants():
  my_covenants_blockchain = electrumQuery("blockchain.covenant.list_by_sender", {
    sender_address: my_address
  })
  
  my_covenants_local = db.query("SELECT covenant_id FROM covenants")
  
  for bc_covenant in my_covenants_blockchain:
    if bc_covenant.covenant_id not in my_covenants_local:
      // Found covenant not in local DB
      db.insert("covenants", bc_covenant)
      log("Synced missing covenant: " + bc_covenant.covenant_id)

Platform-Specific Notes

Android

iOS

Web/Desktop


Testing Strategy

Unit Tests

Integration Tests

Edge Cases


Uses:

Used by:


Status: Phase 0 ✅ Partially Implemented (wallet storage + pending_transactions working), Phase 1+ 🔨 Full design (covenant tracking, cache, sync)
Updated: 2026-08-21 (added pending_transactions schema + SendViewModel RS083 status)
Originally written: 2026-08-04 (pre-production, described full MVP architecture)
Complexity: Medium (database design + sync logic + event-driven reconciliation)
Priority: Core infrastructure (covenants, listings, wallet, caching all depend on this)
Design Principle: “Blockchain is always right. Local state is cache + UI optimizations.”

Phase 0 Implementation (AsgayaHusk):

🏠 Home ↑ Android App 📖 Glossary