asgayapedia

Notification Bot Component

Purpose: Detect bank payment notifications, extract Cash Account from reference, match to covenant, auto-fund with BCH

Complexity: High - Android notification interception, regex parsing, payment matching, auto-funding

Status (September 8, 2026):

Phase 0 ✅ Implemented:

Phase 0 🔨 In progress (Bizum auto-funding — see seller-auto-funding workspace):

Phase 1+ 🔨 Future:

Important: This document’s comprehensive bank-notification parsing describes seller auto-funding. Per the seller-auto-funding decision (Sep 2026), the MVP on-ramp is cash-in-person first (same construct+fund core, manual trigger); Bizum auto-funding builds on it as the Phase 0 second milestone. Phase 0 currently implements Telegram parameter parsing (recipient-side).


⚠️ Telegram’s Role (Phase 0): Test + Emergency Fallback Only

Telegram is NOT the product’s coordination transport. It is:

  1. Testing tool — used during Phase 0 development (copy-paste [COVENANT_V25] blocks)
  2. Emergency fallback — if Nostr fails, Telegram still works

The production coordination layer is Nostr (encrypted DMs, parameter transport). See nostr.md.

Phase 1+ broader transport: the parameter-sharing/notification flow should work across multiple messengers — WhatsApp, LINE, Signal, SMS, etc. This is a low-priority enhancement, but the design should not assume Telegram specifically. The pattern that makes this easy: parameters are always a structured text block ([COVENANT_V25] / [CASH_IN_PERSON]) that any transport can carry, and the NotificationListener parses by tag, not by app.

Implication for implementation: keep the “copy to clipboard → paste anywhere” pattern (transport-agnostic), and keep the parser tag-based. Don’t hardcode Telegram-specific behavior beyond the Phase 0 listener.


Overview

The Notification Bot has two responsibilities:

1. Payment Detection (Passive Selling)

Phase 1+ approach: Auto-fund only if wallet has sufficient BCH (show notification if insufficient) Phase 1++ extension: Auto-buy from exchange if wallet balance low (see Optional Extensions section)

Implementation approach: Android Notification Listener Service (intercepts bank app notifications)

Why push notifications, not SMS? Banks are migrating from SMS to push notifications because:

Legacy SMS support: Some users (grandfathered accounts) still receive SMS for Bizum payments. This is increasingly rare as banks phase out SMS to reduce costs. The smsbridge_loop.py prototype demonstrated SMS parsing works, but Notification Listener Service is the primary approach for Phase 0.

2. Market Price Subscription (Auto-Refund Monitoring)

The bot also maintains Nostr subscriptions to market price feeds for covenant monitoring:

See: Distributed Monitoring for blockchain-as-oracle architecture.

Cost: Zero (pure Nostr subscription, no HTTP polling). 150× reduction vs traditional HTTP API polling.


How It Works (End-to-End)

Lifecycle

1. María creates covenant for Elena#142 (€100 BCH)
   ↓ [Covenant broadcast to bulletin board]

2. Isabel has active listing ("Sell €100-500 EUR")
   ↓ [Notification Bot running in background]

3. María sends Bizum to Isabel: +34612345678, ref "Elena#142"
   ↓ [Bizum app sends notification: "Received €100.50 from María"]

4. Isabel's Notification Bot intercepts notification
   ↓ [Parse: amount=€100.50, reference="Elena#142"]

5. Bot queries Electrum: "Find covenant for Elena#142"
   ↓ [Finds María's unfunded covenant]

6. Bot checks Isabel's wallet: Has 0.0234 BCH? (€107 at current rate)
   ↓ [Yes: Fund covenant. No: Notify Isabel to top up wallet]

7. Bot funds covenant with €107 BCH (if wallet has sufficient balance)
   ↓ [Broadcast funding transaction]

8. Elena gets notified (Electrum detects funded covenant)
   ↓ [Elena claims €100 BCH, €7 buffer returns to Isabel later]

All automatic for Isabel: She just receives fiat, BCH locked without touching phone.


Notification Detection

Android Notification Listener Service

What: Android API that intercepts app notifications

Setup:

AndroidManifest.xml:
<service android:name=".NotificationListener"
         android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
  <intent-filter>
    <action android:name="android.notification.action.NOTIFICATION_LISTENER_SERVICE" />
  </intent-filter>
</service>

Pseudocode:

class NotificationListener extends NotificationListenerService {
  function onNotificationPosted(notification):
    // Check if notification is from bank app
    app_package = notification.packageName
    
    if app_package not in SUPPORTED_BANK_APPS:
      return  // Ignore non-bank notifications
    
    // Extract notification text
    title = notification.extras.get("android.title")
    text = notification.extras.get("android.text")
    
    full_text = title + " " + text
    
    // Parse payment details
    payment = parsePaymentNotification(full_text, app_package)
    
    if payment:
      handlePaymentReceived(payment)
}

Supported bank apps (Phase 1+ - Planned):

User permission required: User must enable notification access in Android settings (one-time setup)


Parsing Bizum Notifications (Spain)

Example notification:

Title: "Bizum recibido"
Text: "Has recibido 100,50 € de María García. Concepto: Elena#142"

Note: This example is based on typical Bizum SMS format. Actual push notification format needs validation with real banking apps. Test data available in knowledge/intelligence/raw_intelligence/ (historical SMS from PoC testing on Pichan/Raspberry Pi). Push notification format may differ slightly from SMS format.

Regex pattern:

function parseBizumNotification(text):
  // Pattern: "recibido <amount> € de <name>. Concepto: <reference>"
  pattern = /recibido\s+([\d,]+)\s*€\s+de\s+([^.]+)\.\s*Concepto:\s*(.+)/i
  
  match = regex_match(pattern, text)
  
  if not match:
    return null
  
  return {
    amount: parse_decimal(match[1]),  // "100,50" → 100.50
    sender_name: match[2].trim(),     // "María García"
    reference: match[3].trim()        // "Elena#142"
  }

Variations by bank:

Hypothesis: Bizum is a collaboration between Spanish banks (Red de Servicios de Bizum), so notification format is likely standardized across all participating banks. The variations above may be SMS-era differences that have been unified in push notifications.

Solution (defensive): Multiple regex patterns per bank, try all until match

Pseudocode:

BIZUM_PATTERNS = [
  /recibido\s+([\d,]+)\s*€\s+de\s+([^.]+)\.\s*Concepto:\s*(.+)/i,
  /Bizum de\s+([^:]+):\s+([\d,]+)\s*€\.\s*Concepto:\s*(.+)/i,
  /Recepción Bizum\s+([\d,]+)\s*€\.\s*De:\s+([^.]+)\.\s*Concepto:\s*(.+)/i
]

function parseBizumNotification(text):
  for pattern in BIZUM_PATTERNS:
    match = regex_match(pattern, text)
    if match:
      return extract_payment_details(match)
  
  return null  // No pattern matched

Testing needed: Validate actual push notification format from BBVA, CaixaBank, Openbank apps. May discover single pattern works for all.


Parsing SEPA Notifications (Europe)

Example notification:

Title: "Transferencia recibida"
Text: "100,50 € de ES12 3456 7890 1234 5678 9012. Concepto: Elena#142"

Regex pattern:

function parseSEPANotification(text):
  // Pattern: "<amount> € de <IBAN>. Concepto: <reference>"
  pattern = /([\d,]+)\s*€\s+de\s+([A-Z]{2}\d{2}\s*(?:\d{4}\s*){4}\d{4})\.\s*Concepto:\s*(.+)/i
  
  match = regex_match(pattern, text)
  
  if not match:
    return null
  
  return {
    amount: parse_decimal(match[1]),
    sender_iban: match[2].replace(/\s/g, ""),  // Remove spaces
    reference: match[3].trim()
  }

Note: SEPA Instant (SCT Inst) transfers complete in seconds, similar to Bizum. Standard SEPA (SCT) takes 1-3 business days. Most Spanish banks now support SEPA Instant. Parsing approach is identical for both.

Reference: https://www.sepaesp.es/sepa/es/secciones/instrumentos/Transferencia-Inmediata/transferencia-inmediata.html

Fraud detection (SEPA Instant feature “g”): Recipient name verification. If the name in the bank account doesn’t match the payment instruction, sender’s bank shows warning. This is a built-in scam protection feature that complements Asgaya’s fraud detection (María’s bot verifies payment.recipient_name == seller.expected_name).


Parsing PagoMóvil Notifications (Venezuela)

Example notification:

Title: "Pago recibido"
Text: "2.500.000,00 Bs de 0412-1234567. Ref: Elena#142"

Regex pattern:

function parsePagoMovilNotification(text):
  // Pattern: "<amount> Bs de <phone>. Ref: <reference>"
  pattern = /([\d.,]+)\s*Bs\s+de\s+([\d-]+)\.\s*Ref:\s*(.+)/i
  
  match = regex_match(pattern, text)
  
  if not match:
    return null
  
  return {
    amount: parse_decimal(match[1]),  // "2.500.000,00" → 2500000.00
    sender_phone: match[2],
    reference: match[3].trim()
  }

Currency conversion needed: Bs → EUR (use exchange rate oracle, Phase 1+)

Market validation: Traditional remittance services charge ~10% fees on EUR→VES corridor. Asgaya can offer 0.5-2% fees (sender fee + seller profit margin), capturing 7-9% savings for users. See knowledge/intelligence/2026-06-26-remittance-quote.md for real market data.


Cash Account Extraction

What to Extract

Goal: Get “Elena#142” from payment reference

Challenges:

Normalization

Pseudocode:

function extractCashAccount(reference):
  // Remove extra whitespace
  clean = reference.trim()
  
  // Pattern: <name>#<number> (supports multi-word names with spaces/hyphens)
  // Examples: "Elena#142", "Bodega Rosa#487", "Farmacia-Caracas#1200"
  pattern = /([a-zA-Z][a-zA-Z0-9\-\s]*?)\s*#\s*(\d+)/i
  
  match = regex_match(pattern, clean)
  
  if not match:
    return null
  
  // Capitalize first letter of each word
  name = capitalize_words(match[1].trim())  // "bodega rosa" → "Bodega Rosa"
  number = parseInt(match[2])
  
  return name + "#" + number  // "Bodega Rosa#487"

Pattern breakdown:

Edge cases:

Initial approach: Require # separator (strict), reject ambiguous references. Later can relax to support legacy formats if needed.


Covenant Matching

Query Electrum for Matching Covenant

What: Find unfunded covenant where recipient = Elena#142

⚠️ Illustrative pseudocode: the electrumQuery(...) method names below are placeholders, not real Electrum RPC methods (there is no blockchain.covenant.list_by_recipient; covenants cannot be enumerated that way). (2026-09-16 — same class as the bulletin-board limitation.)

Pseudocode:

function findCovenantByRecipient(cash_account):
  // Resolve Cash Account to BCH address
  recipient_address = resolveCashAccount(cash_account)
  
  if not recipient_address:
    return null  // Cash Account not registered
  
  // Query Electrum for covenants with this recipient
  covenants = electrumQuery("blockchain.covenant.list_by_recipient", {
    recipient_address: recipient_address,
    status: "unfunded"  // Only unfunded covenants
  })
  
  if covenants.length == 0:
    return null  // No matching covenant
  
  if covenants.length > 1:
    // Multiple matches - use most recent
    covenants.sort(by: created_at, desc)
  
  return covenants[0]

Electrum query:

{
  "method": "blockchain.covenant.list_by_recipient",
  "params": {
    "recipient_address": "bitcoincash:qp...",
    "status": "unfunded"
  }
}

Response:

{
  "result": [
    {
      "covenant_id": "abc123...",
      "recipient": "bitcoincash:qp...",
      "amount": 100.00,
      "buffer": 7.00,
      "total_bch": 0.0234,
      "expires_at": 1735689600,
      "created_at": 1735686000
    }
  ]
}

Matching Logic

Decision tree:

function matchPaymentToCovenant(payment):
  // 1. Extract Cash Account
  cash_account = extractCashAccount(payment.reference)
  
  if not cash_account:
    log("No Cash Account in reference: " + payment.reference)
    return null
  
  // 2. Find covenant
  covenant = findCovenantByRecipient(cash_account)
  
  if not covenant:
    log("No covenant for " + cash_account)
    return null
  
  // 3. Verify amount matches
  expected_amount = covenant.amount + (covenant.amount * 0.005)  // 0.5% sender fee
  tolerance_percent = 0.01  // 1% tolerance (2× the sender fee)
  tolerance_amount = covenant.amount * tolerance_percent
  
  if abs(payment.amount - expected_amount) > tolerance_amount:
    if payment.amount < expected_amount:
      // Underpayment: REJECT - seller would lose money if we fund
      log_warning("Underpayment detected. Expected at least " + (expected_amount - tolerance_amount) + ", got " + payment.amount)
      show_notification("Payment too low (€" + payment.amount + " < €" + expected_amount + "). Contact sender.")
      return null  // Don't fund covenant
    else:
      // Overpayment: ACCEPT - buyer tipped or rounded up
      log("Overpayment accepted: €" + payment.amount + " (expected ~€" + expected_amount + "). Proceeding with funding.")
      // Continue to fund covenant (buyer gets less BCH per EUR, their choice)
  
  // 4. Check expiry
  if now() > covenant.expires_at:
    log("Covenant expired: " + covenant.covenant_id)
    return null
  
  return covenant

Amount tolerance: 1% of covenant amount (percentage-based, not fixed EUR)

Expiry check: Don’t fund expired covenants (BCH would be locked forever)


Auto-Funding

Check Wallet Balance

What: Does Isabel have enough BCH to fund covenant?

Pseudocode:

function hasEnoughBCH(required_bch):
  wallet_balance = getWalletBalance()  // Total BCH in wallet
  
  if wallet_balance >= required_bch:
    return true
  else:
    return false

If insufficient: Buy more from Kraken (see below)


Fund Covenant

What: Create transaction that spends BCH into covenant script

Pseudocode:

function fundCovenant(covenant):
  required_bch = covenant.total_bch
  
  // Select UTXOs
  coins = selectCoins(required_bch)
  
  if not coins:
    return error("Insufficient BCH")
  
  // Build funding transaction
  tx = createTransaction({
    inputs: coins.inputs,
    outputs: [
      {
        value: required_bch,
        script: covenant.covenant_script  // Lock in covenant
      },
      {
        value: coins.change,
        script: p2pkh(my_change_address)  // Change back to Isabel
      }
    ]
  })
  
  // Sign and broadcast
  signed_tx = signTransaction(tx)
  tx_id = broadcast(signed_tx)
  
  // Notify sender (optional)
  sendNostrDM(covenant.sender_pubkey, {
    type: "covenant_funded",
    covenant_id: covenant.covenant_id,
    tx_id: tx_id
  })
  
  return tx_id

Cost: ~€0.0002 (BCH transaction fee)

Timing: 10-30 seconds (sign + broadcast + 1 confirmation)


Exchange Integration (Phase 1+ Extension)

Status: Optional extension, not required for Phase 0

Phase 0 approach: If wallet balance insufficient, show notification to seller (“Received payment but wallet has only X BCH, need Y BCH. Top up wallet to fund covenant.”)

Phase 1+ enhancement: Auto-replenish from exchange (Kraken example below)

Two replenishment strategies:

  1. Just-in-time (use received fiat): When Isabel receives €100 fiat payment and wallet BCH is insufficient, automatically send €107 fiat to exchange, buy BCH, fund covenant
  2. Bulk replenishment (when balance low): When wallet BCH drops below threshold (e.g., €500 worth), trigger automated bank payment to exchange for bulk purchase (e.g., send €1,000, buy BCH in bulk, lower per-transaction costs)

Which strategy?


Strategy 1: Just-in-Time Replenishment

Trigger: Wallet balance < required amount for covenant

Flow:

  1. Isabel receives €100.50 Bizum payment (covenant needs €107 BCH)
  2. Wallet has only €50 worth of BCH
  3. Send €57 (shortage + buffer) from Isabel’s bank to exchange
  4. Buy BCH with received fiat
  5. Withdraw to wallet
  6. Fund covenant

Pseudocode:

function ensureSufficientBCH(required_bch, payment_received_eur):
  wallet_balance = getWalletBalance()
  
  if wallet_balance >= required_bch:
    return  // Already have enough
  
  shortage_bch = required_bch - wallet_balance
  shortage_eur = shortage_bch * getCurrentBCHPrice()
  
  // Send fiat to exchange (automated bank payment)
  sendFiatToExchange(shortage_eur, source="received_payment")
  
  // Buy BCH on exchange
  buyBCHFromExchange(shortage_bch)
  
  // Withdraw to wallet
  withdrawToWallet(shortage_bch)
  
  // Wait for BCH to arrive (10-30 minutes)
  wait_for_wallet_balance_increase(timeout=30_MINUTES)

Limitation: Requires exchange account with EUR balance or automated bank integration


Strategy 2: Bulk Replenishment (Threshold-Based)

Trigger: Wallet BCH drops below seller’s maximum transaction size

Flow:

  1. Isabel’s wallet has €400 worth of BCH
  2. Threshold: €500 (her max listing size)
  3. Trigger automated bank payment: €1,000 → exchange
  4. Buy €1,000 BCH in bulk (better exchange rate)
  5. Withdraw to wallet
  6. Resume covenant funding

Pseudocode:

function checkReplenishmentThreshold():
  wallet_balance_eur = getWalletBalance() * getCurrentBCHPrice()
  replenishment_threshold = seller_max_listing_amount  // e.g., €500
  
  if wallet_balance_eur < replenishment_threshold:
    // Trigger bulk buy
    bulk_amount = replenishment_threshold * 2  // Buy 2× threshold for buffer
    
    // Automated bank payment to exchange
    sendBankPaymentToExchange(bulk_amount)
    
    // Buy BCH in bulk
    buyBCHFromExchange(bulk_amount)
    
    log("Bulk replenishment triggered: buying €" + bulk_amount + " BCH")

Advantage: Lower per-transaction fees (bulk buying), faster covenant funding (wallet always topped up)


Exchange Selection

Kraken (example below): Low fees (0.16-0.26%), reliable API, supports BCH/EUR pairs, SEPA deposits

Alternatives:

Selection criteria:

Pluggable design: App should support multiple exchanges (user configures their preferred exchange + API keys)


Kraken API Integration (Example)

Step 1: Get market price

function getKrakenBCHPrice():
  response = http_get("https://api.kraken.com/0/public/Ticker?pair=BCHEUR")
  
  price_eur = parse_float(response.result.BCHEUR.c[0])  // Current price
  
  return price_eur

Step 2: Calculate EUR needed

function calculateEURForBCH(bch_amount):
  price = getKrakenBCHPrice()
  
  eur_needed = bch_amount * price
  
  // Add 1% for slippage + fees
  eur_with_buffer = eur_needed * 1.01
  
  return eur_with_buffer

Step 3: Place market order

function buyBCHFromKraken(bch_amount):
  // Authenticate with API key
  api_key = getKrakenAPIKey()
  api_secret = getKrakenAPISecret()
  
  // Build order request
  order = {
    pair: "BCHEUR",
    type: "buy",
    ordertype: "market",
    volume: bch_amount
  }
  
  // Sign request (Kraken-specific signature)
  signature = sign_kraken_request(order, api_secret)
  
  // Submit order
  response = http_post("https://api.kraken.com/0/private/AddOrder", 
    body: order,
    headers: {
      "API-Key": api_key,
      "API-Sign": signature
    }
  )
  
  if response.error:
    return error("Kraken order failed: " + response.error)
  
  order_id = response.result.txid[0]
  
  return order_id

Step 4: Withdraw BCH to wallet

function withdrawBCHFromKraken(amount, my_address):
  api_key = getKrakenAPIKey()
  api_secret = getKrakenAPISecret()
  
  withdrawal = {
    asset: "BCH",
    key: my_address,  // Pre-registered withdrawal address
    amount: amount
  }
  
  signature = sign_kraken_request(withdrawal, api_secret)
  
  response = http_post("https://api.kraken.com/0/private/Withdraw",
    body: withdrawal,
    headers: {
      "API-Key": api_key,
      "API-Sign": signature
    }
  )
  
  if response.error:
    return error("Withdrawal failed: " + response.error)
  
  // Wait for BCH to arrive (usually 10-30 minutes)
  wait_for_wallet_balance_increase(timeout=30_MINUTES)
  
  return success

Security: API keys stored encrypted, withdrawal addresses pre-registered on Kraken


Error Handling

Notification Parsing Failures

function parsePaymentNotification(text, app_package):
  payment = null
  
  if app_package in BIZUM_APPS:
    payment = parseBizumNotification(text)
  else if app_package in SEPA_APPS:
    payment = parseSEPANotification(text)
  else if app_package in PAGOMOVIL_APPS:
    payment = parsePagoMovilNotification(text)
  
  if not payment:
    log_warning("Failed to parse notification: " + text)
    // Store for manual review
    save_unparsed_notification(text, app_package)
    show_notification("Received payment but couldn't parse. Check manually.")
    return null
  
  return payment

Manual review UI: Show unparsed notifications, let user manually enter Cash Account


Cash Account Not Found

if not resolveCashAccount(cash_account):
  log("Cash Account not registered: " + cash_account)
  show_notification("Payment received for " + cash_account + " but not registered. Manual action needed.")
  // Don't auto-fund (could be wrong recipient)

Covenant Not Found

if not findCovenantByRecipient(cash_account):
  log("No covenant for " + cash_account)
  show_notification("Payment received for " + cash_account + " but no matching covenant. Contact sender.")
  // Refund fiat? Or wait for sender to create covenant?

Phase 0 approach: Show notification, wait for user to investigate


Insufficient BCH

Phase 0 behavior:

wallet_balance = getWalletBalance()
required_bch = covenant.total_bch

if wallet_balance < required_bch:
  shortage = required_bch - wallet_balance
  log("Insufficient BCH: have " + wallet_balance + ", need " + required_bch)
  
  // Notify seller (Isabel)
  show_notification("Payment received but wallet has only " + wallet_balance + " BCH (need " + required_bch + "). Top up wallet to fund covenant.")
  
  // Save pending funding for manual completion
  save_pending_funding(covenant, shortage)

Phase 0+ enhancement: Notify sender via Nostr

// Also notify sender (María) so she can pick a different seller if needed
sendNostrDM(covenant.sender_pubkey, {
  type: "insufficient_capacity",
  covenant_id: covenant.covenant_id,
  seller_cash_account: my_cash_account,
  message: "Received your payment but temporarily low on BCH. Will fund within 1 hour or you can select a different seller from bulletin board."
})

Benefit: Sender can make real-time decision (wait for current seller to top up, or pick different seller with capacity). Improves UX without adding protocol complexity.

Phase 1+ with exchange integration:

try:
  buyBCHFromExchange(shortage)  // Auto-replenish (see Exchange Integration section)
catch ExchangeAPIError:
  // Fallback to Phase 0 behavior (notify user)
  show_notification("Received payment but can't auto-buy BCH. Manual top-up needed.")

Double-Funding Prevention

Problem: Two notifications for same payment (bank app + SMS)

Solution: Track funded covenants

function handlePaymentReceived(payment):
  covenant = matchPaymentToCovenant(payment)
  
  if not covenant:
    return
  
  // Check if already funded
  if isCovenantAlreadyFunded(covenant.covenant_id):
    log("Covenant already funded, ignoring duplicate notification")
    return
  
  // Mark as funded (before actually funding, prevent race)
  markCovenantAsFunded(covenant.covenant_id)
  
  fundCovenant(covenant)

Storage: Local database (SQLite) tracks funded covenant IDs


Platform-Specific Notes

Android

iOS

Recommendation for Phase 0: Focus on Android (where passive selling works). iOS can launch with manual workflow, upgrade to bank API in Phase 1+ when volume justifies API integration costs.

Web/Desktop


Testing Strategy

Unit Tests

Integration Tests (Testnet)

Edge Cases

Real-World Testing

Critical: Test with actual bank apps (BBVA, CaixaBank, etc.) on real phones


Research References

smsbridge_loop.py:

RS026: Android Notifications Architecture


Uses:

Used by:

Interacts with:

Related mechanism docs:


Status: Phase 0 ✅ Telegram parsing implemented | Phase 1+ 🔨 Bank auto-funding (this doc’s focus)
Updated: 2026-08-14 (status clarified - most of this doc is Phase 1+ design)
Originally written: 2026-06-25 (pre-production, focused on seller auto-funding vision)
Complexity: High (most complex component when fully implemented)
Research: See RS026 (Android notifications), smsbridge_loop.py (early SMS prototype)

Phase 0 implementation: Telegram parameter parsing only (NotificationListenerService for Telegram app)
Phase 1+ scope: Bank notification parsing, seller auto-funding, market price subscription, exchange integration —

🏠 Home ↑ Android App 📖 Glossary