From 9075fb39b85b68fe562ea060c8ae9b25c867f192 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 19:18:27 +0000 Subject: [PATCH] feat(quartz): add BOLT12 offer list (NIP-2421 kind 10058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NIP was updated to publish a recipient's BOLT12 offer(s) in a dedicated replaceable event (kind 10058, `bolt12_offer`) instead of a kind:0 field — this is what ties an offer to a Nostr identity (the author's signature) and gives BOLT12 zaps the send-side addressability that lightning zaps get from lud16. - Bolt12OfferListEvent (kind 10058): a BaseReplaceableEvent holding one or more `["offer","lno1..."]` tags (reusing OfferTag), with offers()/firstOffer() accessors and create/updateOffers factories (mirrors ChatMessageRelayListEvent). - Registered in EventFactory + a KindNames display entry. - Tests: offers round-trip + factory typing, malformed-offer-tag filtering, and updateOffers replacing the offer set while keeping other tags. Discovery: a payer fetches the recipient's latest 10058 and picks an offer. The app-side caching, subscription, editor, and payment intent follow in later commits. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- .../vitorpamplona/quartz/kinds/KindNames.kt | 2 + .../offer/Bolt12OfferListEvent.kt | 102 ++++++++++++++++++ .../quartz/utils/EventFactory.kt | 2 + .../offer/Bolt12OfferListEventTest.kt | 82 ++++++++++++++ 4 files changed, 188 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/offer/Bolt12OfferListEvent.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/offer/Bolt12OfferListEventTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt index b5a13b6e88..a7904e51aa 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -303,6 +303,7 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent @@ -541,6 +542,7 @@ object KindNames { RelayRemoveMemberEvent.KIND to KindName("Relay Remove Member", "43"), OnchainZapEvent.KIND to KindName("Onchain Zap", "BC"), Bolt12ZapEvent.KIND to KindName("Bolt12 Zap", "XX"), + Bolt12OfferListEvent.KIND to KindName("Bolt12 Offers", "XX"), PutUserEvent.KIND to KindName("Group Put User", "29"), RemoveUserEvent.KIND to KindName("Group Remove User", "29"), EditMetadataEvent.KIND to KindName("Group Edit Metadata", "29"), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/offer/Bolt12OfferListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/offer/Bolt12OfferListEvent.kt new file mode 100644 index 0000000000..3c16edb97d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/offer/Bolt12OfferListEvent.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXBolt12Zaps.offer + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.OfferTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-XX: BOLT12 Zaps — a user's **BOLT12 offer list** (kind 10058). + * + * A replaceable event, anchored to the author's pubkey, that publishes one or more + * canonical raw BOLT12 offers (`["offer", "lno1..."]`). This is how a payer + * discovers a recipient's offer before sending a BOLT12 zap — the on-Nostr + * analogue of the `lud16` metadata field NIP-57 lightning zaps use, and the thing + * that ties an offer to a Nostr identity (the author's signature). + * + * Fetch a recipient's latest 10058 and pick one of its offers; when more than one + * is present, a payer MAY select any it supports. Content SHOULD be empty. + */ +@Immutable +class Bolt12OfferListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + /** All canonical raw BOLT12 offers (`lno1...`) this user publishes, in tag order. */ + fun offers(): List = tags.mapNotNull(OfferTag::parse) + + /** The first valid offer, or null when the list carries none. */ + fun firstOffer(): String? = tags.firstNotNullOfOrNull(OfferTag::parse) + + companion object { + const val KIND = 10058 + + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG) + + fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null) + + fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) + + fun createTagArray(offers: List): Array> = + offers + .map { OfferTag.assemble(it) } + .toTypedArray() + + /** Replace the offer set, preserving any non-offer tags an earlier version carried. */ + suspend fun updateOffers( + earlierVersion: Bolt12OfferListEvent, + offers: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): Bolt12OfferListEvent { + val tags = + earlierVersion.tags + .filter { !OfferTag.isTag(it) } + .plus(offers.map { OfferTag.assemble(it) }) + .toTypedArray() + + return signer.sign(createdAt, KIND, tags, earlierVersion.content) + } + + suspend fun create( + offers: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): Bolt12OfferListEvent = signer.sign(createdAt, KIND, createTagArray(offers), "") + + fun create( + offers: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): Bolt12OfferListEvent = signer.sign(createdAt, KIND, createTagArray(offers), "") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 1100748727..bec4ccc5fb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -395,6 +395,7 @@ import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent @@ -733,6 +734,7 @@ class EventFactory { OnchainZapEvent.KIND -> OnchainZapEvent(id, pubKey, createdAt, tags, content, sig) Bolt12ZapEvent.KIND -> Bolt12ZapEvent(id, pubKey, createdAt, tags, content, sig) Bolt12ZapIntentEvent.KIND -> Bolt12ZapIntentEvent(id, pubKey, createdAt, tags, content, sig) + Bolt12OfferListEvent.KIND -> Bolt12OfferListEvent(id, pubKey, createdAt, tags, content, sig) OtsEvent.KIND -> OtsEvent(id, pubKey, createdAt, tags, content, sig) PaymentTargetsEvent.KIND -> PaymentTargetsEvent(id, pubKey, createdAt, tags, content, sig) PeopleListEvent.KIND -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/offer/Bolt12OfferListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/offer/Bolt12OfferListEventTest.kt new file mode 100644 index 0000000000..104ce802e9 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/offer/Bolt12OfferListEventTest.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXBolt12Zaps.offer + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32 +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class Bolt12OfferListEventTest { + private val signer = NostrSignerInternal(KeyPair()) + private val offerA = Bolt12Bech32.encode(Bolt12Bech32.OFFER_HRP, byteArrayOf(1, 2, 3, 4)) + private val offerB = Bolt12Bech32.encode(Bolt12Bech32.OFFER_HRP, byteArrayOf(5, 6, 7, 8)) + + @Test + fun holdsOffersAndResolvesTheFactoryType() = + runTest { + val event = Bolt12OfferListEvent.create(listOf(offerA, offerB), signer) + + assertEquals(Bolt12OfferListEvent.KIND, event.kind) + assertEquals("", event.content) + assertEquals(listOf(offerA, offerB), event.offers()) + assertEquals(offerA, event.firstOffer()) + assertTrue(Event.fromJson(event.toJson()) is Bolt12OfferListEvent) + } + + @Test + fun ignoresMalformedOfferTags() = + runTest { + // Manually build a list with one good and one junk offer tag. + val event = + Bolt12OfferListEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1_700_000_000L, + tags = arrayOf(arrayOf("offer", offerA), arrayOf("offer", "not-an-offer")), + content = "", + sig = "c".repeat(128), + ) + assertEquals(listOf(offerA), event.offers()) + } + + @Test + fun updateOffersReplacesTheOfferSetKeepingOtherTags() = + runTest { + val original = + signer.sign( + 1_700_000_000L, + Bolt12OfferListEvent.KIND, + arrayOf(arrayOf("offer", offerA), arrayOf("alt", "my offers")), + "", + ) as Bolt12OfferListEvent + + val updated = Bolt12OfferListEvent.updateOffers(original, listOf(offerB), signer) + + assertEquals(listOf(offerB), updated.offers()) + // The non-offer tag survives the update. + assertTrue(updated.tags.any { it.isNotEmpty() && it[0] == "alt" && it[1] == "my offers" }) + } +}