We built a complete Silent Payment notification system into Sparrow Wallet using Nostr encrypted DMs. When you send bitcoin to a Nostr identity, the recipient is automatically notified via NIP-17 with everything they need to claim the UTXO — no blockchain scanning required.
This is a follow-up to Part 1, which covered sending to Nostr identities. This part covers receiving.
Silent Payments (BIP 352) solve address reuse by letting senders derive unique Taproot outputs from a single static address. But the receiver has a problem: to detect incoming payments, they must scan almost every Taproot output in every block and test whether it was derived from their scan key. This is computationally expensive.
The sender already knows the derived output address — they computed it during transaction construction. The question is: how do you privately tell the recipient about it?
After broadcasting an SP transaction, Sparrow automatically sends a NIP-17 gift-wrapped DM to the recipient's Nostr pubkey containing the txid, output index, and amount. The recipient's wallet polls Nostr relays, decrypts the notification, and displays the payment — all without touching the blockchain.
Sender (Sparrow) Recipient (Sparrow)
│ │
│ 1. Send SP to user@domain │
│ 2. Broadcast transaction │
│ 3. Auto-send NIP-17 DM: │
│ {txid, vout, amount} │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ Nostr │ │
│ │ Relays │◄───── 4. Poll kind 1059 ──│
│ └──────────┘ │
│ 5. Decrypt 3 layers ──►│
│ 6. Parse notification ─►│
│ 7. Display: 1,000 sats │
When a payment is created via the Contacts tab or NIP-05 lookup, PaymentController registers the SP address → recipient npub mapping in SpNotificationCache. After successful broadcast, HeadersController triggers SpNotificationSender, which:
- Generates a random ephemeral Nostr keypair (sender identity is disposable)
- Constructs the notification:
{"type":"silent-payment-notification","version":"1","txid":"...","vout":0,"amount":1000} - Wraps it in NIP-17/NIP-59 gift wrapping (rumor → seal → gift wrap, three layers of NIP-44 encryption)
- Publishes the gift wrap to multiple relays in a background thread
The UI is never blocked. The user sees their transaction broadcast normally.
The SP Notification dialog (Check SP Notifications button in the Contacts tab) offers three key management options:
- Nostr Connect (Bunker) — NIP-46 remote signer. Private key never touches Sparrow. (Connection to relay.nsec.app intermittent and we were unable to get this consistently working.)
- Saved Key — Enter nsec once, encrypted with Argon2id + AES-256-GCM, stored locally. Subsequent sessions just need the password.
- Manual nsec — Direct entry with option to save.
The receiver polls relays for kind 1059 (gift wrap) events tagged to the recipient's pubkey, decrypts three layers, and parses the SP notification.
Each notification is triple-encrypted per the NIP-59 spec:
Gift Wrap (kind 1059) ← encrypted with ephemeral key, p-tags recipient
└─ Seal (kind 13) ← encrypted with sender key to recipient
└─ Rumor (kind 14) ← unsigned, contains the notification JSON
Relays see only the gift wrap's ephemeral pubkey and the recipient's pubkey. They cannot read the content, identify the sender, or link the notification to a bitcoin transaction. The created_at timestamps are randomized ±48h to prevent timing correlation.
We implemented NIP-44 v2 encryption from scratch in Java using BouncyCastle primitives. The implementation passes the official test vectors:
- Conversation key: ECDH (secp256k1 point multiply) → HKDF-extract only (
HMAC-SHA256(salt=utf8("nip44-v2"), IKM=shared_x)) - Message keys: HKDF-expand only (
PRK=conversation_key, info=nonce, L=76) → split into ChaCha20 key (32), nonce (12), HMAC key (32) - Encryption: Plain ChaCha20 stream cipher (not ChaCha20-Poly1305 AEAD)
- Authentication: HMAC-SHA256 over nonce || ciphertext
- Padding: Power-of-two padding with 2-byte big-endian length prefix, minimum 32 bytes
Verified against test vector: sec1=0x01, sec2=0x02 → conversation_key=c41c7753..., full payload match.
A new sidebar tab in Sparrow for managing Nostr contacts:
- Enter npub or NIP-05 → resolves follow list (kind 3) → batch-fetches kind 0 profiles → extracts SP addresses
- Click-to-select with Shift/Cmd multi-select → Pay N Contacts (creates multi-output SP transaction)
- Contacts persist to
nostr-contacts.jsonin Sparrow's data directory — survive app restarts - Right-click context menu: Copy Pubkey, Copy NIP-05, Copy SP Address
- Search/filter with "SP only" checkbox
For the SP notification receiver, the user's Nostr private key is encrypted using the same Argon2id parameters Sparrow uses for wallet files:
Password → Argon2id(salt, iterations=10, memory=256MB, parallelism=4) → AES-256-GCM key
nsec → AES-GCM-encrypt(key, iv) → nostr-key.enc
On subsequent launches, the Saved Key tab auto-appears if nostr-key.enc exists.
For users who don't want their nsec on disk at all, we implemented a NIP-46 Nostr Connect client:
- Generates
nostrconnect://URI with secret and permissions - Connects to remote signer via relay WebSocket
- Delegates
nip44_decryptcalls to the bunker - NIP-42 AUTH handler for relays that require authentication
The bunker client uses pluggable decryption in Nip17Receiver — the same receiver code works with either a local key or a bunker delegate.
drongo (protocol layer) — BoltTouring/drongo
| File | Purpose |
|---|---|
Nip44.java |
NIP-44 v2 encrypt/decrypt with self-test against spec vectors |
Nip17Sender.java |
NIP-17/NIP-59 gift wrap construction and relay publishing |
Nip17Receiver.java |
Relay polling, multi-layer decryption, pluggable decrypt delegate |
Nip46BunkerClient.java |
NIP-46 Nostr Connect client with NIP-42 AUTH |
NostrKeyStore.java |
Argon2id + AES-256-GCM encrypted nsec storage |
NostrContactStore.java |
JSON persistence for resolved contacts |
NostrContactResolver.java |
Follow list → batch profile resolution |
NostrContactCache.java |
Caffeine cache shared across wallet tabs |
SilentPaymentNotification.java |
Notification data model (txid, vout, amount) |
sparrow (UI layer) — BoltTouring/sparrow
| File | Purpose |
|---|---|
ContactsController.java |
Contacts tab controller (load, filter, pay, check notifications) |
contacts.fxml / contacts.css |
Contacts tab layout and styling |
SpNotificationReceiveDialog.java |
Three-tab receive dialog (Bunker, Saved Key, Manual) |
SpNotificationSender.java |
Post-broadcast notification dispatcher |
SpNotificationCache.java |
Maps SP addresses → recipient npubs during payment setup |
PaymentController.java |
SVG Nostr icon, notification registration, listener race fix |
SendController.java |
Multi-contact → multi-tab payment bridge |
HeadersController.java |
Post-broadcast notification hook |
- UTXO auto-import — Use txid + vout from notification to compute spending key and add to wallet
- Startup auto-poll — Check for new SP notifications on launch using saved key
- Bunker relay transport — Switch to Sparrow's Jetty WebSocket for reliable relay.nsec.app connection
- Sparrow fork: BoltTouring/sparrow
- Drongo fork: BoltTouring/drongo
- Jumble fork: BoltTouring/jumble (SP field in kind 0 profiles — merged upstream)


