Purpose: Track covenant lifecycle, cache blockchain data, manage local persistence
Complexity: Medium - Database design + blockchain state reconciliation
Status Update (August 21, 2026):
pending_transactions table + SendViewModel (RS083, Aug 17-18)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:
- ✅ Wallet storage - Room database with WalletEntity (name, WIF, address, balance, type)
- ✅ WalletManager - Reactive updates via LiveData/Flow
- ✅ Pending transaction persistence (RS083, Aug 17-18) -
pending_transactionstable + SendViewModel so transactions survive activity death (BROADCAST → MEMPOOL → CONFIRMED)- ❌ Covenant tracking - Not needed yet (self-funded sender, no seller coordination)
- ❌ Bulletin board cache - No bulletin board in Phase 0
- ❌ 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).
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.
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”)
❌ Don’t store:
Why? Security (keys), privacy (other users), performance (too much data)
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.
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)
BROADCAST → MEMPOOL → CONFIRMED
└── (dropped from mempool → rebroadcast on resume)
viewModelScope)BROADCASTviewModelScope coroutine polls Electrum every 60s (up to 1 hour), updating MEMPOOL then CONFIRMED with block height/confirmationsonResume() checks for non-confirmed pending transactions and rebroadcasts if droppedAndroid 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.
See the research doc: docs/research/RS083_transaction_broadcast_ui_patterns.md (mirrored from knowledge/research/).
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)
}
Why Core Data?
Alternative: SQLite.swift (lighter weight, less magic)
Why IndexedDB?
Alternative: localStorage (simpler, but synchronous and small)
Created → Funded → Claimed (success path)
↘ Expired (timeout)
↘ Aborted (price drop)
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)")
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
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
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
Local state can drift from blockchain:
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)
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
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.")
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)
Seed phrase: User’s responsibility (write on paper)
Database: Optional (all state can be reconstructed from blockchain)
Scenario: User loses phone, restores on new device with seed phrase
Steps:
covenants = electrumQuery("blockchain.covenant.list_by_sender", {
sender_address: my_address
})
What’s lost: Notification history, cached bulletin board (not critical)
What’s preserved: All covenants, listings, wallet balance (blockchain-backed)
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()
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
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)
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):
pending_transactions table + SendViewModel (RS083, Aug 17-18) — BROADCAST → MEMPOOL → CONFIRMED + rebroadcast on resumetx_hex populated by the v0.2 hybrid WebView build (Aug 20) — hex now available for rebroadcast| 🏠 Home | ↑ Android App | 📖 Glossary |