Merge pull request #541 from barrydeen/feat/dip03-private-zaps
feat(zaps): private zaps via DIP-03 + DM-relay routing
This commit is contained in:
@@ -1139,7 +1139,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) },
|
||||
@@ -1250,7 +1250,7 @@ fun WispNavHost(
|
||||
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
|
||||
},
|
||||
onGoToWallet = { navController.navigate(Routes.WALLET) },
|
||||
canPrivateZap = userHasDmRelays && recipientHasDmRelays
|
||||
canPrivateZap = feedViewModel.hasLocalKeypair && userHasDmRelays && recipientHasDmRelays
|
||||
)
|
||||
}
|
||||
SearchScreen(
|
||||
@@ -1619,7 +1619,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
|
||||
)
|
||||
}
|
||||
@@ -1896,7 +1896,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()
|
||||
@@ -2061,7 +2061,7 @@ fun WispNavHost(
|
||||
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
|
||||
},
|
||||
onGoToWallet = { navController.navigate(Routes.WALLET) },
|
||||
canPrivateZap = hashtagUserHasDmRelays && hashtagRecipientHasDmRelays
|
||||
canPrivateZap = feedViewModel.hasLocalKeypair && hashtagUserHasDmRelays && hashtagRecipientHasDmRelays
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2213,7 +2213,7 @@ fun WispNavHost(
|
||||
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
|
||||
},
|
||||
onGoToWallet = { navController.navigate(Routes.WALLET) },
|
||||
canPrivateZap = setFeedUserHasDmRelays && setFeedRecipientHasDmRelays
|
||||
canPrivateZap = feedViewModel.hasLocalKeypair && setFeedUserHasDmRelays && setFeedRecipientHasDmRelays
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2378,7 +2378,7 @@ fun WispNavHost(
|
||||
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
|
||||
},
|
||||
onGoToWallet = { navController.navigate(Routes.WALLET) },
|
||||
canPrivateZap = articleUserHasDmRelays && articleRecipientHasDmRelays
|
||||
canPrivateZap = feedViewModel.hasLocalKeypair && articleUserHasDmRelays && articleRecipientHasDmRelays
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2582,7 +2582,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) {
|
||||
@@ -3141,7 +3144,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()
|
||||
|
||||
scope.launch {
|
||||
relayInfoRepo.prefetchAll(initialRelays.map { it.url })
|
||||
@@ -977,7 +976,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.wisp.app.nostr.Nip57.getZapperPubkey(event)
|
||||
val zapperPubkey = eventRepo.resolveZapSender(event).first
|
||||
if (zapperPubkey != null && eventRepo.getProfileData(zapperPubkey) == null) {
|
||||
metadataFetcher.addToPendingProfiles(zapperPubkey)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user