feat(quartz): add BOLT12 offer list (NIP-2421 kind 10058)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
This commit is contained in:
Claude
2026-07-24 19:18:27 +00:00
parent 362844ee73
commit 9075fb39b8
4 changed files with 188 additions and 0 deletions
@@ -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"),
@@ -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<Array<String>>,
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<String> = 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<String>): Array<Array<String>> =
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<String>,
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<String>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): Bolt12OfferListEvent = signer.sign(createdAt, KIND, createTagArray(offers), "")
fun create(
offers: List<String>,
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): Bolt12OfferListEvent = signer.sign(createdAt, KIND, createTagArray(offers), "")
}
}
@@ -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)
@@ -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" })
}
}