asgayapedia

Nostr Component

Purpose: Encrypted peer-to-peer messaging for payment instructions between buyer and seller

Complexity: Low-Medium - WebSocket connections + NIP-17 gift-wrapped DMs (NIP-44 encryption, ChaCha20-Poly1305)

⚠️ Phase 0 Priority - Required for Mainnet Launch

Nostr is the coordination layer for payment instructions between sender and BCH seller:

Why Phase 0 (High Priority):

  1. Seamless UX - Integrated directly into Asgaya client, no separate app needed
  2. One-time setup - User installs Asgaya, permissions granted, coordination works
  3. Privacy by default - End-to-end encrypted, censorship-resistant, no phone numbers
  4. No external dependencies - Users don’t need Telegram/WhatsApp accounts

Current status (2026-09-16):

⚠️ UPDATE (2026-09-09) — NIP-17 replaces kind-4: This doc originally specified kind-4 DMs. The design has moved to NIP-17 gift-wrapped DMs (kind-14 rumor → kind-13 seal → kind-1059 gift-wrap):

Telegram drawbacks: Users may not have it, requires separate download/setup, not privacy-focused. Nostr advantage: Built into Asgaya = zero friction for users.


Overview

Nostr enables María and Isabel to coordinate payment details privately:

Key features:


What Is Nostr?

Protocol Overview

Definition: Notes and Other Stuff Transmitted by Relays

Architecture:

No blockchain: Just WebSocket servers passing messages

Why Nostr?


Nostr Identity

Key Pair

Each user has:

Derivation from wallet:

function getNostrKeys():
  // Derive from wallet seed (same entropy as BCH keys)
  wallet_seed = getWalletSeed()
  nostr_private_key = deriveNostrKey(wallet_seed, path="m/44'/1237'/0'/0/0")
  nostr_public_key = getPublicKey(nostr_private_key)
  
  return {
    private_key: nostr_private_key,  // Keep secret
    public_key: nostr_public_key     // Share in listings
  }

Why derive from wallet? One seed phrase backs up everything (BCH + Nostr)

Encoding:

Phase 0 note: today the app generates one Nostr key per wallet (stored alongside it). Deriving it from the wallet seed (path m/44'/1237'/0'/0/0) is the planned direction so a single seed phrase backs up BCH + Nostr.


Relay Management

Connecting to Relays

Strategy: Connect to 3-5 public relays for redundancy

Pseudocode:

RELAYS = [
  "wss://relay.damus.io",
  "wss://relay.snort.social",
  "wss://nos.lol"
]

function connectToRelays():
  connections = []
  
  for relay_url in RELAYS:
    try:
      ws = WebSocket(relay_url)
      ws.onopen = () => {
        log("Connected to " + relay_url)
        subscribeToMessages(ws)
      }
      connections.push(ws)
    catch ConnectionError:
      log_warning("Failed to connect to " + relay_url)
      // Continue with other relays
  
  if connections.length == 0:
    return error("No relays available")
  
  return connections

Redundancy: If 1-2 relays fail, others still work

Fallback: If all relays fail, queue messages for retry when connectivity returns


Subscribing to Messages

What: Tell relay to send me messages addressed to my public key

Pseudocode:

function subscribeToMessages(relay_connection):
  my_public_key = getNostrKeys().public_key
  
  subscribe_message = {
    type: "REQ",
    subscription_id: "asgaya_messages",
    filters: [
      {
        kinds: [1059],  // NIP-17 gift-wraps addressed to me (was kind-4 — see update note)
        "#p": [my_public_key]  // Recipient is me
      }
    ]
  }
  
  relay_connection.send(json_encode(subscribe_message))
  
  relay_connection.onmessage = (event) => {
    handleIncomingMessage(event)
  }

WebSocket message format:

["REQ", "asgaya_messages", {"kinds": [1059], "#p": ["npub1..."]}]

Relay response: Sends all past encrypted DMs + streams new ones


Encrypted Messaging (NIP-44)

Why NIP-44? Current standard (ChaCha20-Poly1305, authenticated). NIP-04 is legacy (AES-256-CBC, deprecated by ecosystem). Recommended by nostr-tools, nostr-sdk, and most clients. In our design NIP-44 is used within NIP-17 (seal + gift-wrap encryption).

Sending a Message

What: Encrypt payment instructions, send to Isabel

NIP-17 three-layer structure (authoritative — see workspace files 00/03):

1. rumor (kind-14):    plain-text payload (e.g. [FUND_COVENANT] block), has id, NO sig
2. seal (kind-13):     rumor NIP-44-encrypted to recipient, SIGNED by sender's real key, no tags
3. gift-wrap (kind-1059): seal NIP-44-encrypted to recipient, signed by a RANDOM ephemeral key,
                       single ["p", recipient] tag, obfuscated created_at (≤2 days past)

Pseudocode:

function sendNostrDM(recipient_pubkey, message_text):
  my_keys = getNostrKeys()
  
  // Layer 1: build rumor (unsigned)
  rumor = {
    kind: 14,
    pubkey: my_keys.public_key,
    created_at: now(),                    // real time (canonical)
    tags: [["p", recipient_pubkey]],
    content: message_text                 // plain text payload
  }
  rumor.id = sha256(serialize(rumor))     // has id, no sig
  
  // Layer 2: seal — rumor NIP-44-encrypted, signed by sender's real key
  seal = {
    kind: 13,
    pubkey: my_keys.public_key,
    created_at: randomNow(),              // obfuscated ≤2 days past
    tags: [],
    content: nip44_encrypt(rumor, my_keys.private_key, recipient_pubkey)
  }
  seal.id = hash(seal)
  seal.sig = schnorr_sign(seal.id, my_keys.private_key)   // BIP-340, sender auth
  
  // Layer 3: gift-wrap — seal NIP-44-encrypted, signed by ephemeral key
  ephemeral_keys = generate_keypair()     // random, disposable per message
  gift_wrap = {
    kind: 1059,
    pubkey: ephemeral_keys.public_key,
    created_at: randomNow(),              // obfuscated ≤2 days past
    tags: [["p", recipient_pubkey]],
    content: nip44_encrypt(seal, ephemeral_keys.private_key, recipient_pubkey)
  }
  gift_wrap.id = hash(gift_wrap)
  gift_wrap.sig = schnorr_sign(gift_wrap.id, ephemeral_keys.private_key)
  
  // Send to all connected relays
  for relay in active_relays:
    relay.send(json_encode(["EVENT", gift_wrap]))
  
  return gift_wrap.id

NIP-44 encryption (simplified):

shared_secret = ECDH(my_private_key, recipient_public_key)
// Derive conversation key using HKDF
conversation_key = HKDF_extract(shared_secret, salt="nip44-v2")
message_keys = HKDF_expand(conversation_key, info=nonce)   // per-message keys
// ChaCha20-Poly1305-style AEAD (ChaCha20 + HMAC-SHA256 per NIP-44 v2)
encrypted = ...                                             // per NIP-44 spec
content = base64(version + nonce + ciphertext + mac)

Actual implementation: Hand-rolled NIP-44 on BouncyCastle + NIP-17 gift-wrap (no nostr library — offline-build constraint). Authoritative spec: NIP-44 + NIP-59 + workspace file 03.


Receiving a Message

What: Unwrap + decrypt incoming gift-wrap from relay (NIP-17)

Pseudocode:

function handleIncomingMessage(gift_wrap):       // kind-1059
  // 1. Verify gift-wrap signature (ephemeral key) — NIP-44: MUST validate before decrypt
  if not verify_signature(gift_wrap):
    log_warning("Invalid gift-wrap signature, ignoring")
    return

  // 2. Decrypt gift-wrap content → recover seal (kind-13)
  my_keys = getNostrKeys()
  seal = nip44_decrypt(
    recipient_private_key: my_keys.private_key,
    sender_public_key: gift_wrap.pubkey,          // ephemeral key
    ciphertext: gift_wrap.content
  )

  // 3. Verify seal signature — authenticates the REAL sender (anti-impersonation)
  if not verify_signature(seal):
    log_warning("Invalid seal signature, ignoring")
    return

  // 4. Decrypt seal content → recover rumor (kind-14)
  rumor = nip44_decrypt(
    recipient_private_key: my_keys.private_key,
    sender_public_key: seal.pubkey,               // real sender key
    ciphertext: seal.content
  )

  // 5. Verify rumor.pubkey == seal.pubkey (NIP-17 anti-impersonation)
  if rumor.pubkey != seal.pubkey:
    log_warning("Sender identity mismatch, ignoring")
    return

  // 6. Route by the payload tag (transport-agnostic tag blocks)
  if rumor.content contains "[FUND_COVENANT]":             handleFundCovenant(rumor.content)     // funding request
  else if rumor.content contains "[COVENANT_V25]":         handleReceivedCovenant(rumor.content) // recipient claim
  else if rumor.content contains "[BCH_FOR_SALE]":         handleBchForSale(rumor.content)       // cash-out request
  else if rumor.content contains "[BCH_PURCHASE_COSIGN]":  handleCashoutQuote(rumor.content)     // merchant's pre-signed quote
  else if rumor.content contains "[SIGNED_TX]":            handleSignedTx(rumor.content)         // co-signed transaction
  else:
    log_warning("Unknown message type: " + rumor.content)

⚠️ Payload note: the JSON message types below (payment_request, payment_instruction, covenant_funded) predate the current transport-agnostic tag-block format. The implemented design ships the tag-block text as the rumor content; the parser handles it unchanged. Treat the JSON examples below as historical.

Message Tag Blocks (implemented)

The payload is plain text. The implemented set:

Tag Direction Purpose
[FUND_COVENANT] sender → seller request the seller to fund the covenant (seller sells BCH)
[COVENANT_V25] sender → recipient deliver a funded covenant to claim
[BCH_FOR_SALE] recipient → merchant “I want to sell BCH” (cash-out request)
[BCH_PURCHASE_COSIGN] merchant → recipient pre-signed offer — fresh oracle + quoteId + expiry
[SIGNED_TX] recipient → merchant the co-signed transaction

[CASH_IN_PERSON] / [CASHOUT_REQUEST] are legacy formats from the earlier recipient-first flow, kept only for backward compatibility.

Liveness = response. There is no heartbeat or presence system: a reply inside the timeout is the proof the other side is online. The cash-out leg finalises in seconds — a single, short-lived quote.


Payment Instruction Format

María → Isabel (Payment Request)

When: After María creates covenant and finds Isabel’s listing

Message payload:

{
  "version": 1,
  "type": "payment_request",
  "covenant_id": "abc123...",
  "recipient_cash_account": "Elena#142",
  "amount_eur": 100
}

Fields:

Purpose: Request Isabel’s payment details so María can pay her


Isabel → María (Payment Instruction)

When: After Isabel receives payment request and verifies covenant

Message payload:

{
  "version": 1,
  "type": "payment_instruction",
  "covenant_id": "abc123...",
  "payment_method": "bizum",
  "payment_details": {
    "phone": "+34654321098",
    "full_name": "Isabel Rodríguez García",
    "reference": "Elena#142"
  },
  "expires_at": 1735689600
}

Fields:

Purpose: Give María everything she needs to complete the Bizum payment

Size: ~200 bytes (no Nostr message limit)


Isabel → María (Covenant Funded Notification)

When: After Isabel locks BCH in covenant (OPTIONAL - Electrum is primary detection)

Message payload:

{
  "version": 1,
  "type": "covenant_funded",
  "covenant_id": "abc123...",
  "funded_at": 1735689000,
  "tx_id": "def456..."
}

Purpose: Prompt María to query Electrum immediately (faster than polling)

Not critical: María will detect funding via Electrum monitoring regardless


Error Handling

Relay Failures

try:
  relay.send(message)
catch WebSocketError:
  // Mark relay as failed
  mark_relay_failed(relay)
  
  // Try remaining relays
  if other_relays_available:
    log("Relay failed, message sent via other relays")
    return success
  else:
    // Queue for retry
    queue_message_for_retry(message)
    show_message("Offline. Message queued.")

Message Not Delivered

Problem: No delivery confirmation in Nostr (fire-and-forget)

Solution: Timeout + fallback

function sendWithRetry(recipient, message):
  sent_at = now()
  
  sendNostrDM(recipient, message)
  
  // Wait for acknowledgment (custom Asgaya convention)
  wait_for_ack(timeout=30_SECONDS)
  
  if not received_ack:
    // Retry once
    log("No ack, retrying")
    sendNostrDM(recipient, message)
    
    wait_for_ack(timeout=30_SECONDS)
    
    if not received_ack:
      // Fallback: On-chain message (OP_RETURN)
      log_warning("Nostr failed, falling back to on-chain")
      sendOnChainMessage(recipient, message)

On-chain fallback (last resort):


Decryption Failures

try:
  plaintext = nip44_decrypt(ciphertext)
catch DecryptionError:
  // Message not for me, or corrupted
  log_warning("Failed to decrypt message from " + sender)
  // Silently ignore (don't crash)

Invalid Message Format

try:
  message = json_decode(plaintext)
  validate_schema(message)
catch ParseError:
  log_warning("Invalid message format from " + sender)
  show_notification("Received malformed message (ignored)")

Platform-Specific Notes

Android

iOS

Web/Desktop


Nostr Libraries (Recommendations)

Android

iOS

Web

Recommendation: Use library for NIP-44 encryption (complex crypto: HKDF key derivation + ChaCha20-Poly1305 authenticated encryption). Raw WebSocket is fine for relay management.


Privacy Considerations

What’s Private

What’s Public (Relay Can See)

Metadata privacy: Relays see who talks to whom, but not what’s said

Mitigation (Phase 1+): Use Tor or VPN when connecting to relays


Testing Strategy

Unit Tests

Integration Tests

Edge Cases


Uses:

Used by:

Interacts with:


Status: ✅ Working end-to-end (NIP-17) — funding, recipient claim, and cash-out
Updated: 2026-09-16 (implementation shipped: recipient claim + cash-out over Nostr)
Originally written: 2026-08-04
Complexity: Low-Medium (WebSocket + NIP-17 gift-wrap + NIP-44)
Priority: Essential coordination layer (sender ↔ seller payment instructions)
Current: Telegram bot (testing/fallback only) | Production: Nostr required (integrated UX)

Note: Updated to NIP-44 (current standard: ChaCha20-Poly1305). NIP-04 (kind-4) is legacy (AES-256-CBC, deprecated). DMs use NIP-17 gift-wrap (kind-14 → kind-13 seal → kind-1059). Authoritative implementation design: collaborative_workspace/nostr-transport/ (files 00, 03, 09, 10, 12).

🏠 Home ↑ Android App 📖 Glossary