From 6be8f69372e8d6cfe38847e396f0d573b4a6c64b Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 15 Sep 2026 14:49:42 +0200 Subject: [PATCH 1/4] fix(nwc): read the zapped note (e/a tag) out of transaction metadata --- .../rpc/NwcTransactionMetadata.kt | 33 +++++--- .../NwcOutgoingMetadataTest.kt | 76 ++++++++++++++++++- 2 files changed, 99 insertions(+), 10 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt index c95e636604..46770c9698 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip47WalletConnect.rpc import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.RawJson import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.utils.Hex class NwcTransactionMetadata( val comment: String?, @@ -44,6 +45,8 @@ class NwcTransactionMetadata( val pubkeyHex: String?, val recipientPubkeyHex: String?, val content: String?, + val zappedEventId: String? = null, + val zappedAddress: String? = null, ) fun senderPubkeyHex(): String? = nostr?.pubkeyHex ?: payerData?.pubkey?.let { decodePublicKeyAsHexOrNull(it) } @@ -54,6 +57,13 @@ class NwcTransactionMetadata( fun recipientPubkeyHex(): String? = nostr?.recipientPubkeyHex + /** + * The note this zap was for: the `a` address for an addressable target (so the + * living version opens, not the one that was zapped), else the `e` id. Null for + * a profile zap, which legitimately carries neither. + */ + fun zappedNoteId(): String? = nostr?.zappedAddress ?: nostr?.zappedEventId + /** * The message to show for this transaction. * @@ -90,21 +100,26 @@ class NwcTransactionMetadata( val rawPubkey = n["pubkey"] as? String val pubkeyHex = rawPubkey?.let { decodePublicKeyAsHexOrNull(it) } - val tags = n["tags"] as? List<*> - val recipientHex = - tags?.firstNotNullOfOrNull { tag -> - val tagList = tag as? List<*> - if (tagList != null && tagList.size >= 2 && tagList[0] == "p") { - tagList[1] as? String - } else { - null - } + var recipientHex: String? = null + var zappedEventId: String? = null + var zappedAddress: String? = null + (n["tags"] as? List<*>)?.forEach { tag -> + val tagList = tag as? List<*> ?: return@forEach + if (tagList.size < 2) return@forEach + val value = tagList[1] as? String ?: return@forEach + when (tagList[0]) { + "p" -> if (recipientHex == null) recipientHex = value + "e" -> if (zappedEventId == null && value.length == 64 && Hex.isHex64(value)) zappedEventId = value + "a" -> if (zappedAddress == null) zappedAddress = value } + } NostrZapData( pubkeyHex = pubkeyHex, recipientPubkeyHex = recipientHex, content = n["content"] as? String, + zappedEventId = zappedEventId, + zappedAddress = zappedAddress, ) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcOutgoingMetadataTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcOutgoingMetadataTest.kt index 1a20c1987c..5b26d05454 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcOutgoingMetadataTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcOutgoingMetadataTest.kt @@ -44,15 +44,23 @@ class NwcOutgoingMetadataTest { private val recipientHex = "ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b" private val payerHex = "f512822a89d2369a386bfeb1e687ccd26ceb6bb33e73b98417499bb9054bff1f" + private val noteHex = "d".repeat(64) + private fun zapRequest( content: String = "great post", relays: List = listOf("wss://relay.damus.io"), + zappedNoteId: String? = null, ) = Event( id = "a".repeat(64), pubKey = payerHex, createdAt = 1756000000L, kind = 9734, - tags = arrayOf(arrayOf("p", recipientHex), arrayOf("relays", *relays.toTypedArray())), + tags = + listOfNotNull( + zappedNoteId?.let { arrayOf("e", it) }, + arrayOf("p", recipientHex), + arrayOf("relays", *relays.toTypedArray()), + ).toTypedArray(), content = content, sig = "b".repeat(128), ) @@ -233,6 +241,72 @@ class NwcOutgoingMetadataTest { assertEquals("for the article", parsed.displayComment()) } + @Test + fun theZappedNoteSurvivesTheRoundTrip() { + val wire = + OptimizedJsonMapper.toJson( + PayInvoiceMethod.create("lnbc1", NwcTransactionMetadata.build(zapRequest(zappedNoteId = noteHex), "user@domain.com", "")), + ) + val back = OptimizedJsonMapper.fromJsonTo(wire) as PayInvoiceMethod + val parsed = assertNotNull(NwcTransactionMetadata.parse(back.params?.metadata)) + + assertEquals(noteHex, parsed.zappedNoteId()) + assertEquals(recipientHex, parsed.recipientPubkeyHex(), "the p tag is still read with the e tag in front of it") + } + + @Test + fun aProfileZapHasNoNote() { + val parsed = + assertNotNull( + NwcTransactionMetadata.parse(mapOf("nostr" to mapOf("tags" to listOf(listOf("p", recipientHex))))), + ) + assertNull(parsed.zappedNoteId()) + } + + @Test + fun tagOrderDoesNotMatter() { + val parsed = + assertNotNull( + NwcTransactionMetadata.parse( + mapOf("nostr" to mapOf("tags" to listOf(listOf("e", noteHex), listOf("p", recipientHex)))), + ), + ) + assertEquals(recipientHex, parsed.recipientPubkeyHex()) + assertEquals(noteHex, parsed.zappedNoteId()) + } + + @Test + fun anAddressableZapPrefersTheAddress() { + val address = "30023:$recipientHex:my-article" + val parsed = + assertNotNull( + NwcTransactionMetadata.parse( + mapOf("nostr" to mapOf("tags" to listOf(listOf("e", noteHex), listOf("a", address), listOf("p", recipientHex)))), + ), + ) + assertEquals(address, parsed.zappedNoteId()) + } + + @Test + fun aMalformedEventIdIsIgnored() { + val parsed = + assertNotNull( + NwcTransactionMetadata.parse( + mapOf("nostr" to mapOf("tags" to listOf(listOf("e", "not-a-note"), listOf("p", recipientHex)))), + ), + ) + assertNull(parsed.zappedNoteId()) + + // isHex64 checks only the first 64 chars; a longer id must not slip through. + val tooLong = + assertNotNull( + NwcTransactionMetadata.parse( + mapOf("nostr" to mapOf("tags" to listOf(listOf("e", noteHex + "ff"), listOf("p", recipientHex)))), + ), + ) + assertNull(tooLong.zappedNoteId()) + } + @Test fun blankFieldsReadAsAbsent() { val parsed = From 49d3cd89890408b486bf53e858ebf80175f3db03 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 15 Sep 2026 14:53:18 +0200 Subject: [PATCH 2/4] feat(wallet): expose the zapped note on the transaction row model --- .../loggedIn/wallet/TransactionRowLabels.kt | 3 +++ .../ui/wallet/TransactionRowLabelsTest.kt | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TransactionRowLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TransactionRowLabels.kt index f3254b872a..6f157fd4c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TransactionRowLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TransactionRowLabels.kt @@ -34,6 +34,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionType data class TransactionRowLabels( val title: Title, val subtitle: String?, + /** The note a zap was for (event id or `a` address), or null for a profile zap or a plain payment. */ + val zappedNoteId: String?, ) { sealed interface Title { /** Render the counterparty's profile for this pubkey, falling back to [name]. */ @@ -83,6 +85,7 @@ data class TransactionRowLabels( return TransactionRowLabels( title = named ?: Title.Literal(fallback), subtitle = comment ?: fallback.takeIf { named != null }, + zappedNoteId = parsed?.zappedNoteId(), ) } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/wallet/TransactionRowLabelsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/wallet/TransactionRowLabelsTest.kt index 81c39d94be..6e34dfe90e 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/wallet/TransactionRowLabelsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/wallet/TransactionRowLabelsTest.kt @@ -116,4 +116,29 @@ class TransactionRowLabelsTest { // The comment merely repeats the description, so it is not shown twice. assertEquals("Test", labels.subtitle) } + + /** A note zap's `e` tag reaches the row, so it can open the zapped note. */ + @Test + fun aNoteZapCarriesTheNoteId() { + val noteHex = "d".repeat(64) + val labels = + TransactionRowLabels.resolve( + NwcTransaction( + type = "outgoing", + metadata = mapOf("nostr" to mapOf("tags" to listOf(listOf("e", noteHex), listOf("p", recipientHex)))), + ), + "Sent", + ) + assertEquals(noteHex, labels.zappedNoteId) + } + + @Test + fun aProfileZapCarriesNoNoteId() { + val labels = + TransactionRowLabels.resolve( + NwcTransaction(type = "outgoing", metadata = mapOf("nostr" to mapOf("tags" to listOf(listOf("p", recipientHex))))), + "Sent", + ) + assertNull(labels.zappedNoteId) + } } From 29fb6760dfaedb217ed24426ec5b4f0d9ac24a1a Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 15 Sep 2026 14:55:33 +0200 Subject: [PATCH 3/4] feat(wallet): tap a zap in wallet history to open the zapped note --- .../wallet/WalletTransactionsScreen.kt | 27 ++++++++++++++++++- .../composeResources/values/strings.xml | 1 + 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt index dcb39e2879..ecfa6e6065 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -65,11 +66,13 @@ import com.vitorpamplona.amethyst.commons.resources.wallet_filter_zaps import com.vitorpamplona.amethyst.commons.resources.wallet_incoming import com.vitorpamplona.amethyst.commons.resources.wallet_loading import com.vitorpamplona.amethyst.commons.resources.wallet_no_transactions +import com.vitorpamplona.amethyst.commons.resources.wallet_open_zapped_note import com.vitorpamplona.amethyst.commons.resources.wallet_outgoing import com.vitorpamplona.amethyst.commons.resources.wallet_refresh import com.vitorpamplona.amethyst.commons.resources.wallet_transactions import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.formatMonthDayTime @@ -289,12 +292,23 @@ private fun TransactionItem( TransactionRowLabels.resolve(tx, directionLabel) } val counterpartyPubkeyHex = (labels.title as? TransactionRowLabels.Title.User)?.pubkeyHex + val zappedNoteId = labels.zappedNoteId Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), + .then( + // Before the padding, so the whole row height is the target. The + // picture keeps its own tap (profile): picture -> profile, row -> note. + if (zappedNoteId != null) { + Modifier.clickable(onClickLabel = stringRes(Res.string.wallet_open_zapped_note)) { + nav.nav(Route.Note(zappedNoteId)) + } + } else { + Modifier + }, + ).padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { if (counterpartyPubkeyHex != null) { @@ -368,6 +382,17 @@ private fun TransactionItem( MaterialTheme.colorScheme.onBackground }, ) + + if (zappedNoteId != null) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + symbol = MaterialSymbols.ChevronRight, + // The row's onClickLabel already announces it. + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index cdb9318316..d38864bbfa 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -1662,6 +1662,7 @@ No transactions match this filter Loading… Received + Open zapped note Sent Refresh All From d79b93327412f45e37b97d4fdabd790a564e4d21 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 15 Sep 2026 15:21:39 +0200 Subject: [PATCH 4/4] refactor(nwc): validate zapped a/e tags with the existing Address and HexKey helpers --- .../rpc/NwcTransactionMetadata.kt | 11 +++-- .../NwcOutgoingMetadataTest.kt | 48 ++++++------------- 2 files changed, 20 insertions(+), 39 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt index 46770c9698..7d90f3b256 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.quartz.nip47WalletConnect.rpc +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.RawJson +import com.vitorpamplona.quartz.nip01Core.core.isValid import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull -import com.vitorpamplona.quartz.utils.Hex class NwcTransactionMetadata( val comment: String?, @@ -45,8 +46,8 @@ class NwcTransactionMetadata( val pubkeyHex: String?, val recipientPubkeyHex: String?, val content: String?, - val zappedEventId: String? = null, - val zappedAddress: String? = null, + val zappedEventId: String?, + val zappedAddress: String?, ) fun senderPubkeyHex(): String? = nostr?.pubkeyHex ?: payerData?.pubkey?.let { decodePublicKeyAsHexOrNull(it) } @@ -109,8 +110,8 @@ class NwcTransactionMetadata( val value = tagList[1] as? String ?: return@forEach when (tagList[0]) { "p" -> if (recipientHex == null) recipientHex = value - "e" -> if (zappedEventId == null && value.length == 64 && Hex.isHex64(value)) zappedEventId = value - "a" -> if (zappedAddress == null) zappedAddress = value + "e" -> if (zappedEventId == null && value.isValid()) zappedEventId = value + "a" -> if (zappedAddress == null) zappedAddress = Address.parse(value)?.toValue() } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcOutgoingMetadataTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcOutgoingMetadataTest.kt index 5b26d05454..8bddfc1446 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcOutgoingMetadataTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcOutgoingMetadataTest.kt @@ -254,23 +254,16 @@ class NwcOutgoingMetadataTest { assertEquals(recipientHex, parsed.recipientPubkeyHex(), "the p tag is still read with the e tag in front of it") } + private fun parseTags(vararg tags: List) = assertNotNull(NwcTransactionMetadata.parse(mapOf("nostr" to mapOf("tags" to tags.toList())))) + @Test fun aProfileZapHasNoNote() { - val parsed = - assertNotNull( - NwcTransactionMetadata.parse(mapOf("nostr" to mapOf("tags" to listOf(listOf("p", recipientHex))))), - ) - assertNull(parsed.zappedNoteId()) + assertNull(parseTags(listOf("p", recipientHex)).zappedNoteId()) } @Test fun tagOrderDoesNotMatter() { - val parsed = - assertNotNull( - NwcTransactionMetadata.parse( - mapOf("nostr" to mapOf("tags" to listOf(listOf("e", noteHex), listOf("p", recipientHex)))), - ), - ) + val parsed = parseTags(listOf("e", noteHex), listOf("p", recipientHex)) assertEquals(recipientHex, parsed.recipientPubkeyHex()) assertEquals(noteHex, parsed.zappedNoteId()) } @@ -278,33 +271,20 @@ class NwcOutgoingMetadataTest { @Test fun anAddressableZapPrefersTheAddress() { val address = "30023:$recipientHex:my-article" - val parsed = - assertNotNull( - NwcTransactionMetadata.parse( - mapOf("nostr" to mapOf("tags" to listOf(listOf("e", noteHex), listOf("a", address), listOf("p", recipientHex)))), - ), - ) - assertEquals(address, parsed.zappedNoteId()) + assertEquals(address, parseTags(listOf("e", noteHex), listOf("a", address), listOf("p", recipientHex)).zappedNoteId()) + } + + @Test + fun aMalformedAddressFallsBackToTheEventId() { + assertEquals(noteHex, parseTags(listOf("e", noteHex), listOf("a", "not-an-address"), listOf("p", recipientHex)).zappedNoteId()) } @Test fun aMalformedEventIdIsIgnored() { - val parsed = - assertNotNull( - NwcTransactionMetadata.parse( - mapOf("nostr" to mapOf("tags" to listOf(listOf("e", "not-a-note"), listOf("p", recipientHex)))), - ), - ) - assertNull(parsed.zappedNoteId()) - - // isHex64 checks only the first 64 chars; a longer id must not slip through. - val tooLong = - assertNotNull( - NwcTransactionMetadata.parse( - mapOf("nostr" to mapOf("tags" to listOf(listOf("e", noteHex + "ff"), listOf("p", recipientHex)))), - ), - ) - assertNull(tooLong.zappedNoteId()) + // "noteHex + ff": the id check must bound the length, not just scan 64 hex chars. + listOf("not-a-note", noteHex + "ff").forEach { bad -> + assertNull(parseTags(listOf("e", bad), listOf("p", recipientHex)).zappedNoteId(), bad) + } } @Test