feat(zaps): private zaps via DIP-03 + DM-relay routing (port wisp#541)

Replaces the relays-tag heuristic with DIP-03: the real sender signs an
inner kind 9733 event, NIP-04-encrypted into the outer kind 9734's anon
tag using an ephemeral key derived as sha256(privkey + eventId +
createdAt). Relays and the LNURL provider only see the unlinkable
ephemeral pubkey; receipts route through both parties' DM relays.
EventRepository.resolveZapSender() decrypts incoming private zaps (as
recipient) and re-derives the ephemeral key to self-attribute our own
outgoing ones, so notifications, zap lists, and wallet history show the
real counterparty. Private zaps require a local keypair, so the UI gate
adds hasLocalKeypair; live-stream (addressable) zaps can't derive the
ephemeral key and stay public.
This commit is contained in:
Barry Deen
2026-06-10 12:01:23 -04:00
parent 0308bad87c
commit 4a87103944
13 changed files with 295 additions and 82 deletions
@@ -1112,7 +1112,7 @@ fun WispNavHost(
zapSuccess = feedViewModel.zapSuccess,
zapError = feedViewModel.zapError,
zapInProgressIds = profileZapInProgress,
canPrivateZap = feedViewModel.relayPool.hasDmRelays() && feedViewModel.relayListRepo.hasDmRelays(pubkey),
canPrivateZap = feedViewModel.hasLocalKeypair && feedViewModel.relayPool.hasDmRelays() && feedViewModel.relayListRepo.hasDmRelays(pubkey),
fetchDmRelays = { pk -> feedViewModel.fetchDmRelaysIfMissing(pk) && feedViewModel.relayPool.hasDmRelays() },
ownLists = feedViewModel.listRepo.ownLists.collectAsState().value,
onAddToList = { dTag, pk -> feedViewModel.addToList(dTag, pk) },
@@ -1222,7 +1222,7 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = userHasDmRelays && recipientHasDmRelays
canPrivateZap = feedViewModel.hasLocalKeypair && userHasDmRelays && recipientHasDmRelays
)
}
SearchScreen(
@@ -1590,7 +1590,7 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = feedViewModel.relayPool.hasDmRelays() && recipientHasDmRelays,
canPrivateZap = feedViewModel.hasLocalKeypair && feedViewModel.relayPool.hasDmRelays() && recipientHasDmRelays,
initialSatsHint = groupRoomZapInitialSats
)
}
@@ -1867,7 +1867,7 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = threadUserHasDmRelays && threadRecipientHasDmRelays
canPrivateZap = feedViewModel.hasLocalKeypair && threadUserHasDmRelays && threadRecipientHasDmRelays
)
}
val threadSetListedIds by feedViewModel.bookmarkSetRepo.allListedEventIds.collectAsState()
@@ -2031,7 +2031,7 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = hashtagUserHasDmRelays && hashtagRecipientHasDmRelays
canPrivateZap = feedViewModel.hasLocalKeypair && hashtagUserHasDmRelays && hashtagRecipientHasDmRelays
)
}
@@ -2182,7 +2182,7 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = setFeedUserHasDmRelays && setFeedRecipientHasDmRelays
canPrivateZap = feedViewModel.hasLocalKeypair && setFeedUserHasDmRelays && setFeedRecipientHasDmRelays
)
}
@@ -2346,7 +2346,7 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = articleUserHasDmRelays && articleRecipientHasDmRelays
canPrivateZap = feedViewModel.hasLocalKeypair && articleUserHasDmRelays && articleRecipientHasDmRelays
)
}
@@ -2550,7 +2550,10 @@ fun WispNavHost(
eventATag = aTag)
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = feedViewModel.relayPool.hasDmRelays() && recipientHasDmRelays
// DIP-03 needs a concrete note id for the ephemeral key
// derivation; live-stream zaps target an addressable event
// (a-tag) instead, so private zaps don't apply here.
canPrivateZap = false
)
}
val streamActivityEventId = remember(hostPubkey, dTag) {
@@ -3107,7 +3110,7 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = notifUserHasDmRelays && notifRecipientHasDmRelays
canPrivateZap = feedViewModel.hasLocalKeypair && notifUserHasDmRelays && notifRecipientHasDmRelays
)
}
@@ -24,11 +24,7 @@ object Nip04 {
* Returns: base64(ciphertext) + "?iv=" + base64(iv)
*/
fun encrypt(plaintext: String, sharedSecret: ByteArray): String {
val iv = ByteArray(16).also { random.nextBytes(it) }
val key = SecretKeySpec(sharedSecret, "AES")
val cipher = cipherLocal.get()
cipher.init(Cipher.ENCRYPT_MODE, key, IvParameterSpec(iv))
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
val (ciphertext, iv) = encryptRaw(plaintext, sharedSecret)
val ctB64 = Base64.encodeToString(ciphertext, Base64.NO_WRAP)
val ivB64 = Base64.encodeToString(iv, Base64.NO_WRAP)
return "$ctB64?iv=$ivB64"
@@ -42,8 +38,24 @@ object Nip04 {
require(parts.size == 2) { "Invalid NIP-04 content format" }
val ciphertext = Base64.decode(parts[0], Base64.DEFAULT)
val iv = Base64.decode(parts[1], Base64.DEFAULT)
return decryptRaw(ciphertext, iv, sharedSecret)
}
/** Raw AES-256-CBC encrypt with random IV. Returns (ciphertext, iv) so callers
* can package the bytes in a non-standard envelope (e.g. DIP-03 bech32). */
fun encryptRaw(plaintext: String, sharedSecret: ByteArray): Pair<ByteArray, ByteArray> {
val iv = ByteArray(16).also { random.nextBytes(it) }
val key = SecretKeySpec(sharedSecret, "AES")
val cipher = cipherLocal.get()
val cipher = cipherLocal.get()!!
cipher.init(Cipher.ENCRYPT_MODE, key, IvParameterSpec(iv))
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
return ciphertext to iv
}
/** Raw AES-256-CBC decrypt — counterpart to [encryptRaw]. */
fun decryptRaw(ciphertext: ByteArray, iv: ByteArray, sharedSecret: ByteArray): String {
val key = SecretKeySpec(sharedSecret, "AES")
val cipher = cipherLocal.get()!!
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
return String(cipher.doFinal(ciphertext), Charsets.UTF_8)
}
@@ -186,7 +186,7 @@ object Nip19 {
}
}
private fun bech32Encode(hrp: String, data: ByteArray): String {
internal fun bech32Encode(hrp: String, data: ByteArray): String {
val values = convertBits(data, 8, 5, true)
val checksum = bech32Checksum(hrp, values)
return buildString(hrp.length + 1 + values.size + 6) {
@@ -197,7 +197,7 @@ object Nip19 {
}
}
private fun bech32Decode(str: String): Pair<String, ByteArray> {
internal fun bech32Decode(str: String): Pair<String, ByteArray> {
val lower = str.lowercase()
val pos = lower.lastIndexOf('1')
require(pos >= 1) { "Invalid bech32 string" }
@@ -10,11 +10,19 @@ import kotlinx.serialization.json.long
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.URLEncoder
import java.security.MessageDigest
object Nip57 {
const val KIND_PRIVATE_ZAP_EVENT = 9733
private const val ANON_TAG = "anon"
private const val PZAP_HRP = "pzap"
private const val IV_HRP = "iv"
private val bolt11AmountRegex = Regex("""lnbc(\d+)([munp]?)1""")
private val json = Json { ignoreUnknownKeys = true }
data class DecryptedPrivateZap(val senderPubkey: String, val message: String)
fun getZappedEventId(event: NostrEvent): String? {
return event.tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1)
}
@@ -157,6 +165,142 @@ object Nip57 {
return signer.signEvent(kind = 9734, content = message, tags = tags)
}
/**
* DIP-03 deterministic ephemeral private key:
* sha256(utf8(senderPrivkeyHex + targetEventIdHex + createdAtString)).
* Re-derivable from public note data so the sender can identify their own
* outgoing private zaps after the fact.
*/
fun deriveEphemeralPrivkey(
senderPrivkey: ByteArray,
targetEventId: String,
targetCreatedAt: Long
): ByteArray {
val input = senderPrivkey.toHex() + targetEventId + targetCreatedAt.toString()
return MessageDigest.getInstance("SHA-256")
.digest(input.toByteArray(Charsets.UTF_8))
}
/**
* DIP-03 private zap request. Inner kind 9733 (real sender, NIP-04 encrypted
* to recipient via ephemeral ECDH) is packed into the outer kind 9734's
* `anon` tag; the outer event is signed by the deterministic ephemeral key
* so the LNURL provider and relays only see an unlinkable pubkey.
*/
fun buildPrivateZapRequest(
senderPrivkey: ByteArray,
senderPubkey: ByteArray,
recipientPubkey: String,
eventId: String,
eventCreatedAt: Long,
amountMsats: Long,
relayUrls: List<String>,
lnurl: String,
message: String,
extraTags: List<List<String>> = emptyList()
): NostrEvent {
val ephPrivkey = deriveEphemeralPrivkey(senderPrivkey, eventId, eventCreatedAt)
val ephPubkey = Keys.xOnlyPubkey(ephPrivkey)
val inner = NostrEvent.create(
privkey = senderPrivkey,
pubkey = senderPubkey,
kind = KIND_PRIVATE_ZAP_EVENT,
content = message,
tags = listOf(listOf("p", recipientPubkey))
)
val shared = Nip04.computeSharedSecret(ephPrivkey, recipientPubkey.hexToByteArray())
val (ct, iv) = Nip04.encryptRaw(inner.toJson(), shared)
val anonValue = Nip19.bech32Encode(PZAP_HRP, ct) + "_" + Nip19.bech32Encode(IV_HRP, iv)
val outerTags = buildList {
add(listOf("p", recipientPubkey))
add(listOf("e", eventId))
add(listOf("relays") + relayUrls)
add(listOf("amount", amountMsats.toString()))
add(listOf("lnurl", lnurl))
add(listOf(ANON_TAG, anonValue))
addAll(extraTags)
}
return NostrEvent.create(
privkey = ephPrivkey,
pubkey = ephPubkey,
kind = 9734,
content = "",
tags = outerTags
)
}
/** True if the receipt's embedded zap request carries a DIP-03 `anon` tag. */
fun isPrivateZap(receipt: NostrEvent): Boolean {
val request = parseEmbeddedRequest(receipt) ?: return false
return request.tags.any { it.size >= 2 && it[0] == ANON_TAG }
}
/**
* Decrypt a DIP-03 private zap addressed to us (we are the recipient).
* Returns null unless the `anon` envelope decodes, AES-decrypts, and the
* inner kind 9733's Schnorr signature checks out the anon tag is
* otherwise unauthenticated, so signature verification is mandatory.
*/
fun decryptPrivateZap(receipt: NostrEvent, myPrivkey: ByteArray): DecryptedPrivateZap? {
val request = parseEmbeddedRequest(receipt) ?: return null
val anon = request.tags.firstOrNull { it.size >= 2 && it[0] == ANON_TAG }?.get(1)
?: return null
val shared = runCatching {
Nip04.computeSharedSecret(myPrivkey, request.pubkey.hexToByteArray())
}.getOrNull() ?: return null
return decryptAnonTag(anon, shared)
}
/**
* Decrypt a DIP-03 private zap we sent ourselves. Re-derives the
* deterministic ephemeral key from [target], confirms the outer pubkey
* matches (i.e. this is ours), then runs ECDH(ephPriv, recipient) to
* recover the same shared secret used at encryption. Returns null for any
* receipt that isn't one of ours.
*/
fun decryptOwnOutgoingPrivateZap(
receipt: NostrEvent,
myPrivkey: ByteArray,
target: NostrEvent
): DecryptedPrivateZap? {
val request = parseEmbeddedRequest(receipt) ?: return null
val anon = request.tags.firstOrNull { it.size >= 2 && it[0] == ANON_TAG }?.get(1)
?: return null
val ephPriv = deriveEphemeralPrivkey(myPrivkey, target.id, target.created_at)
val ephPubHex = runCatching { Keys.xOnlyPubkey(ephPriv).toHex() }.getOrNull()
?: return null
if (ephPubHex != request.pubkey) return null
val recipientHex = request.tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
?: return null
val shared = runCatching {
Nip04.computeSharedSecret(ephPriv, recipientHex.hexToByteArray())
}.getOrNull() ?: return null
return decryptAnonTag(anon, shared)
}
private fun decryptAnonTag(anon: String, sharedSecret: ByteArray): DecryptedPrivateZap? {
val parts = anon.split("_", limit = 2)
if (parts.size != 2) return null
val (hrpCt, ct) = runCatching { Nip19.bech32Decode(parts[0]) }.getOrNull() ?: return null
val (hrpIv, iv) = runCatching { Nip19.bech32Decode(parts[1]) }.getOrNull() ?: return null
if (hrpCt != PZAP_HRP || hrpIv != IV_HRP) return null
val plaintext = runCatching { Nip04.decryptRaw(ct, iv, sharedSecret) }.getOrNull()
?: return null
val inner = runCatching { NostrEvent.fromJson(plaintext) }.getOrNull() ?: return null
if (inner.kind != KIND_PRIVATE_ZAP_EVENT) return null
if (!inner.verifySignature()) return null
return DecryptedPrivateZap(inner.pubkey, inner.content)
}
private fun parseEmbeddedRequest(receipt: NostrEvent): NostrEvent? {
val description = receipt.tags.firstOrNull { it.size >= 2 && it[0] == "description" }?.get(1)
?: return null
return runCatching { NostrEvent.fromJson(description) }.getOrNull()
}
suspend fun fetchSimpleInvoice(
callbackUrl: String,
amountMsats: Long,
@@ -41,8 +41,8 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo:
var contactRepo: ContactRepository? = null
var safetyPrefs: SafetyPreferences? = null
var extendedNetworkRepo: ExtendedNetworkRepository? = null
/** Set of current user's DM relay URLs — used to detect private zaps. */
var dmRelayUrls: Set<String> = emptySet()
/** Late-bound for DIP-03 private zap decryption (recipient + self-attribution). */
var keyRepo: KeyRepository? = null
private val eventCache = ConcurrentHashMap<String, NostrEvent>()
private val seenEventIds = ConcurrentHashMap.newKeySet<String>() // thread-safe dedup that doesn't evict
@@ -456,7 +456,7 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo:
7 -> addReaction(event)
30315 -> processUserStatus(event)
9735 -> {
val zapperPk = Nip57.getZapperPubkey(event)
val (zapperPk, zapMessage) = resolveZapSender(event)
if (zapperPk != null && isWotFiltered(zapperPk, 9735)) return
val targetId = Nip57.getZappedEventId(event)
?: resolveAddressableTarget(event)
@@ -470,31 +470,27 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo:
synchronized(dedupSet) { if (!dedupSet.add(event.id)) return }
val sats = Nip57.getZapAmountSats(event)
if (sats > 0) {
val zapperPubkey = Nip57.getZapperPubkey(event)
// Skip if this is our own zap and we already added it optimistically
val isOwnOptimistic = zapperPubkey == currentUserPubkey && optimisticZaps.remove(targetId)
val isOwnOptimistic = zapperPk == currentUserPubkey && optimisticZaps.remove(targetId)
if (!isOwnOptimistic) {
addZapSats(targetId, sats)
if (zapperPubkey != null) {
val zapMessage = Nip57.getZapMessage(event)
val isPrivateZap = dmRelayUrls.isNotEmpty() && Nip57.getZapRequestRelays(event).let { reqRelays ->
reqRelays.isNotEmpty() && reqRelays.all { it in dmRelayUrls }
}
if (zapperPk != null) {
val isPrivateZap = Nip57.isPrivateZap(event)
val zaps = zapDetails.get(targetId)
?: java.util.Collections.synchronizedList(mutableListOf<ZapDetail>()).also {
zapDetails.put(targetId, it)
}
zaps.add(ZapDetail(zapperPubkey, sats, zapMessage, isPrivate = isPrivateZap, receiptEventId = event.id))
zaps.add(ZapDetail(zapperPk, sats, zapMessage, isPrivate = isPrivateZap, receiptEventId = event.id))
}
}
// Always mark user zap flag from receipts
if (zapperPubkey == currentUserPubkey) {
if (zapperPk == currentUserPubkey) {
userZaps.put(targetId, true)
}
// Check if this zap is a zap poll vote (kind 6969)
val targetEvent = eventCache[targetId]
if (targetEvent?.kind == Nip69.KIND_ZAP_POLL && sats > 0) {
addZapPollVote(event, targetId, sats, zapperPubkey)
addZapPollVote(event, targetId, sats, zapperPk)
}
}
}
@@ -808,6 +804,30 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo:
return event
}
/**
* Resolve the real sender + message of a kind 9735 zap receipt:
* 1. DIP-03 recipient path decrypt the anon tag with our privkey.
* 2. DIP-03 self-attribution match the outer ephemeral against one
* derived from our privkey + the target note's (id, created_at).
* 3. Public-zap fallback read the embedded request's pubkey/content.
*
* Returns (senderPubkey, message). senderPubkey is null only if the
* embedded request is missing/malformed.
*/
fun resolveZapSender(receipt: NostrEvent): Pair<String?, String> {
val priv = keyRepo?.getKeypair()?.privkey
if (priv != null) {
Nip57.decryptPrivateZap(receipt, priv)?.let { return it.senderPubkey to it.message }
val targetId = receipt.tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1)
val target = targetId?.let { getEvent(it) }
if (target != null) {
Nip57.decryptOwnOutgoingPrivateZap(receipt, priv, target)
?.let { return it.senderPubkey to it.message }
}
}
return Nip57.getZapperPubkey(receipt) to Nip57.getZapMessage(receipt)
}
/**
* Bulk-load events from ObjectBox into eventCache and seenEventIds without running
* the full addEvent pipeline (no engagement counts, no feed insertion). Profiles
@@ -868,8 +888,10 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo:
val bolt11 = event.tags.firstOrNull { it.size >= 2 && it[0] == "bolt11" }?.get(1) ?: continue
val decoded = Bolt11.decode(bolt11)
val hash = decoded?.paymentHash ?: continue
// Sender from embedded kind 9734 zap request
val sender = Nip57.getZapperPubkey(event)
// Sender from embedded kind 9734 zap request — for DIP-03 private
// zaps, resolveZapSender decrypts so wallet history shows the real
// counterparty rather than the ephemeral pubkey.
val sender = resolveZapSender(event).first
if (sender != null) senders[hash] = sender
// Recipient from 'p' tag of the receipt
val recipient = event.tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
@@ -181,14 +181,14 @@ class NotificationRepository(
// For zap receipts, event.pubkey is the lightning service — check the actual zapper too.
if (muteRepo?.isBlocked(event.pubkey) == true) return
if (event.kind == 9735) {
val zapperPubkey = Nip57.getZapperPubkey(event)
val zapperPubkey = eventRepo?.resolveZapSender(event)?.first
if (zapperPubkey != null && muteRepo?.isBlocked(zapperPubkey) == true) return
}
if (safetyPrefs?.wotFilterEnabled?.value == true) {
val netRepo = extendedNetworkRepo
if (netRepo != null && netRepo.isNetworkReady()) {
val pubkeyToCheck = if (event.kind == 9735) {
Nip57.getZapperPubkey(event) ?: event.pubkey
eventRepo?.resolveZapSender(event)?.first ?: event.pubkey
} else event.pubkey
if (!netRepo.isInQualifiedNetwork(pubkeyToCheck)) return
}
@@ -592,7 +592,8 @@ class NotificationRepository(
private fun mergeZap(event: NostrEvent): Boolean {
val amount = Nip57.getZapAmountSats(event)
if (amount <= 0) return false
val zapperPubkey = Nip57.getZapperPubkey(event) ?: return false
val (zapperPubkey, message) = eventRepo?.resolveZapSender(event) ?: (null to "")
if (zapperPubkey == null) return false
val zapETag = event.tags.firstOrNull { it.size >= 2 && it[0] == "e" }
?: return mergeProfileZap(event, zapperPubkey, amount)
val referencedId = zapETag[1]
@@ -602,11 +603,7 @@ class NotificationRepository(
// the same 9735 event to be re-processed on periodic refresh cycles.
val zapEventIds = zapEventIdsByGroup.getOrPut(key) { mutableSetOf() }
if (!zapEventIds.add(event.id)) return false
val message = Nip57.getZapMessage(event)
val dmRelays = eventRepo?.dmRelayUrls ?: emptySet()
val isPrivate = dmRelays.isNotEmpty() && Nip57.getZapRequestRelays(event).let { reqRelays ->
reqRelays.isNotEmpty() && reqRelays.all { it in dmRelays }
}
val isPrivate = Nip57.isPrivateZap(event)
val entry = ZapEntry(pubkey = zapperPubkey, sats = amount, message = message, createdAt = event.created_at, receiptEventId = event.id, isPrivate = isPrivate)
val emoji = NotificationGroup.ZAP_EMOJI
val existing = groupMap[key] as? NotificationGroup.ReactionGroup
@@ -663,7 +660,7 @@ class NotificationRepository(
}
private fun mergeProfileZap(event: NostrEvent, zapperPubkey: String, amount: Long): Boolean {
val message = Nip57.getZapMessage(event)
val message = eventRepo?.resolveZapSender(event)?.second ?: Nip57.getZapMessage(event)
val recipientPubkey = event.tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) ?: return false
val flatZapId = "profilezap:${event.id}"
if (flatItemIds.add(flatZapId)) {
@@ -79,7 +79,8 @@ class ZapSender(
isAnonymous: Boolean = false,
isPrivate: Boolean = false,
extraTags: List<List<String>> = emptyList(),
extraRelayHints: List<String> = emptyList()
extraRelayHints: List<String> = emptyList(),
eventCreatedAt: Long? = null
): Result<Unit> {
// 1. LNURL discovery
val payInfo = Nip57.resolveLud16(recipientLud16, httpClient)
@@ -93,20 +94,22 @@ class ZapSender(
return Result.failure(Exception("Amount out of range (${payInfo.minSendable / 1000}-${payInfo.maxSendable / 1000} sats)"))
}
// 2. Build zap request (kind 9734)
// 2. Build zap request (kind 9734). For DIP-03 private zaps we route
// the receipt only to both parties' NIP-51 DM relays — assumed to be
// AUTH-gated for reads — so the LNURL-published kind 9735 (which
// carries the recipient pubkey, amount, and target note id) never lands
// on a publicly readable relay. The anon-tag envelope hides the sender
// identity as a second layer in case the LNURL ignores `relays` or one
// of the DM relays turns out to serve reads unauthed.
val relayUrls = if (isPrivate) {
// Private zap: route receipt through DM relays only
val recipientDmRelays = relayListRepo.getDmRelays(recipientPubkey) ?: emptyList()
val ourDmRelays = relayPool.getDmRelayUrls()
val combined = (recipientDmRelays + ourDmRelays).distinct().take(5)
val combined = (ourDmRelays + recipientDmRelays).distinct().take(5)
if (combined.isEmpty()) {
return Result.failure(Exception("No DM relays available for private zap"))
return Result.failure(Exception("Private zaps require DM relays on both sides"))
}
combined
} else {
// Extra relay hints first (e.g. live stream chat relays), then recipient's
// read relays (so they see the receipt), then our own read relays (so we can
// verify it), deduped, capped at 5.
val recipientRelays = relayListRepo.getReadRelays(recipientPubkey) ?: emptyList()
val ourRelays = relayPool.getReadRelayUrls()
(extraRelayHints + recipientRelays + ourRelays).distinct().take(5)
@@ -118,26 +121,12 @@ class ZapSender(
addAll(extraTags)
}
val zapRequest = if (isAnonymous) {
val throwaway = Keys.generate()
Nip57.buildZapRequest(
senderPrivkey = throwaway.privkey,
senderPubkey = throwaway.pubkey,
recipientPubkey = recipientPubkey,
eventId = eventId,
amountMsats = amountMsats,
relayUrls = relayUrls,
lnurl = recipientLud16,
message = message,
extraTags = allExtraTags
)
} else {
val s = signer
val keypair = keyRepo.getKeypair()
when {
s != null -> Nip57.buildZapRequestWithSigner(
signer = s,
val zapRequest = when {
isAnonymous -> {
val throwaway = Keys.generate()
Nip57.buildZapRequest(
senderPrivkey = throwaway.privkey,
senderPubkey = throwaway.pubkey,
recipientPubkey = recipientPubkey,
eventId = eventId,
amountMsats = amountMsats,
@@ -146,18 +135,57 @@ class ZapSender(
message = message,
extraTags = allExtraTags
)
keypair != null -> Nip57.buildZapRequest(
}
isPrivate -> {
// DIP-03 requires a concrete note target (id + created_at) to
// derive the deterministic ephemeral key. Profile / addressable
// zaps fall back to non-private at the UI gate; this is a
// defensive guard.
if (eventId == null || eventCreatedAt == null) {
return Result.failure(Exception("Private zaps require a note target"))
}
val keypair = keyRepo.getKeypair()
?: return Result.failure(Exception("Private zaps require a local private key"))
Nip57.buildPrivateZapRequest(
senderPrivkey = keypair.privkey,
senderPubkey = keypair.pubkey,
recipientPubkey = recipientPubkey,
eventId = eventId,
eventCreatedAt = eventCreatedAt,
amountMsats = amountMsats,
relayUrls = relayUrls,
lnurl = recipientLud16,
message = message,
extraTags = allExtraTags
)
else -> return Result.failure(Exception("No signer or keypair available"))
}
else -> {
val s = signer
val keypair = keyRepo.getKeypair()
when {
s != null -> Nip57.buildZapRequestWithSigner(
signer = s,
recipientPubkey = recipientPubkey,
eventId = eventId,
amountMsats = amountMsats,
relayUrls = relayUrls,
lnurl = recipientLud16,
message = message,
extraTags = allExtraTags
)
keypair != null -> Nip57.buildZapRequest(
senderPrivkey = keypair.privkey,
senderPubkey = keypair.pubkey,
recipientPubkey = recipientPubkey,
eventId = eventId,
amountMsats = amountMsats,
relayUrls = relayUrls,
lnurl = recipientLud16,
message = message,
extraTags = allExtraTags
)
else -> return Result.failure(Exception("No signer or keypair available"))
}
}
}
@@ -151,7 +151,7 @@ class ArticleViewModel : ViewModel() {
7, 6 -> eventRepo.addEvent(event)
9735 -> {
eventRepo.addEvent(event)
val zapperPubkey = Nip57.getZapperPubkey(event)
val zapperPubkey = eventRepo.resolveZapSender(event).first
if (zapperPubkey != null && eventRepo.getProfileData(zapperPubkey) == null) {
metadataFetcher.addToPendingProfiles(zapperPubkey)
}
@@ -134,7 +134,7 @@ class EventRouter(
if (convKey != null && msgId != null) {
val sats = Nip57.getZapAmountSats(event)
if (sats > 0) {
val zapperPubkey = Nip57.getZapperPubkey(event) ?: event.pubkey
val zapperPubkey = eventRepo.resolveZapSender(event).first ?: event.pubkey
dmRepo.addZap(convKey, msgId, DmZap(zapperPubkey, sats, event.created_at))
}
}
@@ -154,7 +154,7 @@ class EventRouter(
metadataFetcher.addToPendingProfiles(event.pubkey)
}
if (event.kind == 9735) {
val zapperPubkey = Nip57.getZapperPubkey(event)
val zapperPubkey = eventRepo.resolveZapSender(event).first
if (zapperPubkey != null && eventRepo.getProfileData(zapperPubkey) == null) {
metadataFetcher.addToPendingProfiles(zapperPubkey)
}
@@ -220,7 +220,7 @@ class EventRouter(
if (event.kind == 9735) {
eventRepo.addEvent(event)
eventRepo.addEventRelay(event.id, relayUrl)
val zapperPubkey = Nip57.getZapperPubkey(event)
val zapperPubkey = eventRepo.resolveZapSender(event).first
if (zapperPubkey != null && eventRepo.getProfileData(zapperPubkey) == null) {
metadataFetcher.addToPendingProfiles(zapperPubkey)
}
@@ -253,7 +253,7 @@ class EventRouter(
9735 -> {
eventRepo.addEvent(event)
eventRepo.addEventRelay(event.id, relayUrl)
val zapperPubkey = Nip57.getZapperPubkey(event)
val zapperPubkey = eventRepo.resolveZapSender(event).first
if (zapperPubkey != null && eventRepo.getProfileData(zapperPubkey) == null) {
metadataFetcher.addToPendingProfiles(zapperPubkey)
}
@@ -389,7 +389,6 @@ class EventRouter(
val urls = Nip51.parseRelaySet(event)
keyRepo.saveDmRelays(urls)
relayPool.updateDmRelays(urls)
eventRepo.dmRelayUrls = urls.toSet()
}
}
if (event.kind == Nip51.KIND_SEARCH_RELAYS) {
@@ -135,6 +135,11 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) {
val keyRepo = KeyRepository(app)
private val pubkeyHex: String? = keyRepo.getPubkeyHex()
/** True when the account has a local private key (i.e. not a remote/NIP-07 signer).
* DIP-03 private zaps require local signing for both ephemeral derivation and
* ECDH decryption, so this gates the UI toggle. */
val hasLocalKeypair: Boolean = keyRepo.getKeypair() != null
var signer: NostrSigner? = null
private set
@@ -181,6 +186,7 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) {
it.currentUserPubkey = pubkeyHex
it.deletedEventsRepo = deletedEventsRepo
it.eventPersistence = eventPersistence
it.keyRepo = keyRepo
}
val contactRepo = ContactRepository(app, pubkeyHex).also {
eventRepo.contactRepo = it
@@ -381,7 +381,10 @@ class SocialActionManager(
} else {
subscribeZapReceipt(event.id)
}
// For private zaps, also subscribe on DM relays for the receipt
// For private zaps, also subscribe on our DM relays — that's where
// the LNURL will publish the receipt (per ZapSender's `relays` tag).
// Subscriptions over DM relays go through NIP-42 AUTH automatically
// via the relay's existing auth handshake.
if (isPrivate && relayPool.hasDmRelays()) {
val dmFilter = if (eventATag != null) {
Filter(kinds = listOf(9735), aTags = listOf(eventATag))
@@ -418,7 +421,8 @@ class SocialActionManager(
isAnonymous = isAnonymous,
isPrivate = isPrivate,
extraTags = zapExtraTags,
extraRelayHints = extraRelayHints
extraRelayHints = extraRelayHints,
eventCreatedAt = event.created_at
)
_zapInProgress.value = _zapInProgress.value - event.id
result.fold(
@@ -225,7 +225,6 @@ class StartupCoordinator(
relayPool.updateRelays(initialRelays)
val dmRelays = keyRepo.getDmRelays()
relayPool.updateDmRelays(dmRelays)
eventRepo.dmRelayUrls = dmRelays.toSet()
// Connect local relay if configured
relayPool.updateLocalRelay(keyRepo.getLocalRelay(), getUserPubkey())
@@ -987,7 +986,6 @@ class StartupCoordinator(
relayPool.updateRelays(relays)
val dmRelays = keyRepo.getDmRelays()
relayPool.updateDmRelays(dmRelays)
eventRepo.dmRelayUrls = dmRelays.toSet()
feedSub.subscribeFeed()
}
}
@@ -229,7 +229,7 @@ class ThreadViewModel : ViewModel() {
5, 7, 6, 1018 -> eventRepo.addEvent(event)
9735 -> {
eventRepo.addEvent(event)
val zapperPubkey = com.darkwisp.app.nostr.Nip57.getZapperPubkey(event)
val zapperPubkey = eventRepo.resolveZapSender(event).first
if (zapperPubkey != null && eventRepo.getProfileData(zapperPubkey) == null) {
metadataFetcher.addToPendingProfiles(zapperPubkey)
}