feat(amethyst): cache + keep-live the BOLT12 offer list (kind 10058)

Downloads and keeps kind 10058 fresh the way the per-user relay/payment lists do,
so a recipient's BOLT12 offer is available for discovery before a zap.

- LocalCache consumes 10058 as an addressable note (consumeBaseReplaceable),
  keyed by Address(10058, pubkey, "").
- User gains a pinned bolt12OfferListNote + bolt12OfferList()/bolt12Offers()
  accessors — this is how a payer reads a recipient's offers.
- Bolt12OfferListState: the logged-in user's own offer list as live account state
  (a StateFlow<List<String>> of offers), persisted across restarts and published
  via saveOffers() — cloned from NipA3PaymentTargetsState. Wired into Account
  (bolt12OfferList + saveBolt12Offers), AccountSettings (backup + updater), and
  LocalPreferences (persist/restore).
- Subscriptions: kind 10058 added to FilterUserMetadataForKey (fetches OTHER
  users' offers — the key edit for zap recipients) and to the account's
  FilterAccountInfoAndListsFromKey (fetches your own at login).

Editor UI and the payment intent follow next.

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:28:43 +00:00
parent 9075fb39b8
commit c5d89ff7a0
8 changed files with 132 additions and 0 deletions
@@ -70,6 +70,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
@@ -211,6 +212,7 @@ private object PrefKeys {
const val ALL_ACCOUNT_INFO = "all_saved_accounts_info"
const val SHARED_SETTINGS = "shared_settings"
const val LATEST_PAYMENT_TARGETS = "latestPaymentTargets"
const val LATEST_BOLT12_OFFERS = "latestBolt12Offers"
const val LATEST_CASHU_WALLET = "latestCashuWallet"
const val LATEST_NUTZAP_INFO = "latestNutzapInfo"
}
@@ -588,6 +590,7 @@ object LocalPreferences {
putOrRemove(PrefKeys.LATEST_KEY_PACKAGE_RELAY_LIST, settings.backupKeyPackageRelayList)
putOrRemove(PrefKeys.LATEST_FAVORITE_ALGO_FEEDS_LIST, settings.backupFavoriteAlgoFeedsList)
putOrRemove(PrefKeys.LATEST_PAYMENT_TARGETS, settings.backupNipA3PaymentTargets)
putOrRemove(PrefKeys.LATEST_BOLT12_OFFERS, settings.backupBolt12Offers)
putOrRemove(PrefKeys.LATEST_CASHU_WALLET, settings.backupCashuWallet)
putOrRemove(PrefKeys.LATEST_NUTZAP_INFO, settings.backupNutzapInfo)
@@ -777,6 +780,7 @@ object LocalPreferences {
val latestKeyPackageRelayListStr = getString(PrefKeys.LATEST_KEY_PACKAGE_RELAY_LIST, null)
val latestFavoriteAlgoFeedsListStr = getString(PrefKeys.LATEST_FAVORITE_ALGO_FEEDS_LIST, null)
val latestPaymentTargetsStr = getString(PrefKeys.LATEST_PAYMENT_TARGETS, null)
val latestBolt12OffersStr = getString(PrefKeys.LATEST_BOLT12_OFFERS, null)
val latestCashuWalletStr = getString(PrefKeys.LATEST_CASHU_WALLET, null)
val latestNutzapInfoStr = getString(PrefKeys.LATEST_NUTZAP_INFO, null)
val lastReadPerRouteStr = getString(PrefKeys.LAST_READ_PER_ROUTE, null)
@@ -840,6 +844,7 @@ object LocalPreferences {
val latestKeyPackageRelayList = async { parseEventOrNull<KeyPackageRelayListEvent>(latestKeyPackageRelayListStr) }
val latestFavoriteAlgoFeedsList = async { parseEventOrNull<FavoriteAlgoFeedsListEvent>(latestFavoriteAlgoFeedsListStr) }
val latestPaymentTargets = async { parseEventOrNull<PaymentTargetsEvent>(latestPaymentTargetsStr) }
val latestBolt12Offers = async { parseEventOrNull<Bolt12OfferListEvent>(latestBolt12OffersStr) }
val latestCashuWallet =
async {
parseEventOrNull<com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent>(latestCashuWalletStr)
@@ -895,6 +900,7 @@ object LocalPreferences {
val latestKeyPackageRelayListResolved = latestKeyPackageRelayList.await()
val latestFavoriteAlgoFeedsListResolved = latestFavoriteAlgoFeedsList.await()
val latestPaymentTargetsResolved = latestPaymentTargets.await()
val latestBolt12OffersResolved = latestBolt12Offers.await()
val latestCashuWalletResolved = latestCashuWallet.await()
val latestNutzapInfoResolved = latestNutzapInfo.await()
@@ -997,6 +1003,7 @@ object LocalPreferences {
viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIdsResolved),
pendingAttestations = MutableStateFlow(pendingAttestationsResolved),
backupNipA3PaymentTargets = latestPaymentTargetsResolved,
backupBolt12Offers = latestBolt12OffersResolved,
backupCashuWallet = latestCashuWalletResolved,
backupNutzapInfo = latestNutzapInfoResolved,
callsEnabled = MutableStateFlow(callsEnabled),
@@ -77,6 +77,7 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
import com.vitorpamplona.amethyst.model.bolt12Offers.Bolt12OfferListState
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
import com.vitorpamplona.amethyst.model.localRelays.ForwardKind0ToLocalRelayState
@@ -760,6 +761,8 @@ class Account(
val paymentTargetsState = NipA3PaymentTargetsState(signer, cache, scope, settings)
val bolt12OfferList = Bolt12OfferListState(signer, cache, scope, settings)
val feedDecryptionCaches =
FeedDecryptionCaches(
peopleListCache = peopleListDecryptionCache,
@@ -5541,6 +5544,8 @@ class Account(
suspend fun savePaymentTargets(targets: List<PaymentTarget>) = sendMyPublicAndPrivateOutbox(paymentTargetsState.savePaymentTargets(targets))
suspend fun saveBolt12Offers(offers: List<String>) = sendMyPublicAndPrivateOutbox(bolt12OfferList.saveOffers(offers))
fun markAsRead(
route: String,
timestampInSecs: Long,
@@ -75,6 +75,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
@@ -322,6 +323,7 @@ class AccountSettings(
val viewedPollResultNoteIds: MutableStateFlow<Map<String, Long>> = MutableStateFlow(mapOf()),
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow(mapOf()),
var backupNipA3PaymentTargets: PaymentTargetsEvent? = null,
var backupBolt12Offers: Bolt12OfferListEvent? = null,
var callTurnServers: List<CallTurnServer> = emptyList(),
var callVideoResolution: CallVideoResolution = CallVideoResolution.HD_720,
var callMaxBitrateBps: Int = 1_500_000,
@@ -1260,6 +1262,16 @@ class AccountSettings(
}
}
fun updateBolt12Offers(newBolt12Offers: Bolt12OfferListEvent?) {
if (newBolt12Offers == null || newBolt12Offers.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupBolt12Offers?.id != newBolt12Offers.id) {
backupBolt12Offers = newBolt12Offers
saveAccountSettings()
}
}
fun updateSearchRelayList(newSearchRelayList: SearchRelayListEvent?) {
if (newSearchRelayList == null || newSearchRelayList.tags.isEmpty()) return
@@ -404,6 +404,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.verify.Bolt12ZapValidation
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
@@ -4260,6 +4261,10 @@ object LocalCache : ILocalCache, ICacheProvider {
consumeBaseReplaceable(event, relay, wasVerified)
}
is Bolt12OfferListEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is ClassifiedsEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
@@ -0,0 +1,90 @@
/*
* 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.amethyst.model.bolt12Offers
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
/**
* The logged-in user's NIP-XX BOLT12 offer list (kind 10058) as live account state,
* mirroring [com.vitorpamplona.amethyst.model.nipA3PaymentTargets.NipA3PaymentTargetsState].
* Exposes the current offers as a [flow], persists them across restarts (via
* [AccountSettings]), and publishes updates with [saveOffers].
*/
class Bolt12OfferListState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
val bolt12OfferListNote = cache.getOrCreateAddressableNote(getBolt12OfferListAddress())
fun getBolt12OfferListFlow(): StateFlow<NoteState> = bolt12OfferListNote.flow().metadata.stateFlow
fun getBolt12OfferListAddress() = Bolt12OfferListEvent.createAddress(signer.pubKey)
fun getBolt12OfferListEvent(): Bolt12OfferListEvent? = bolt12OfferListNote.event as? Bolt12OfferListEvent
/** The user's currently-published canonical raw BOLT12 offers. */
val flow: StateFlow<List<String>> =
getBolt12OfferListFlow()
.map { (it.note.event as? Bolt12OfferListEvent)?.offers() ?: emptyList() }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptyList())
suspend fun saveOffers(offers: List<String>): Bolt12OfferListEvent {
val existing = getBolt12OfferListEvent()
return if (existing != null && existing.tags.isNotEmpty()) {
Bolt12OfferListEvent.updateOffers(existing, offers, signer)
} else {
Bolt12OfferListEvent.create(offers, signer)
}
}
init {
settings.backupBolt12Offers?.let {
Log.d("AccountRegisterObservers") { "Loading saved BOLT12 offer list ${it.toJson()}" }
@OptIn(DelicateCoroutinesApi::class)
scope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) }
}
scope.launch(Dispatchers.IO) {
getBolt12OfferListFlow().collect {
(it.note.event as? Bolt12OfferListEvent)?.let { offerListEvent ->
settings.updateBolt12Offers(offerListEvent)
}
}
}
}
}
@@ -53,6 +53,7 @@ import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
val AccountInfoAndListsFromKeyKinds =
listOf(
@@ -80,6 +81,7 @@ val AccountInfoAndListsFromKeyKinds2 =
GeohashListEvent.KIND,
TrustProviderListEvent.KIND,
PaymentTargetsEvent.KIND,
Bolt12OfferListEvent.KIND,
RelayFeedsListEvent.KIND,
InterestSetEvent.KIND,
// NIP-51 "simple groups" list (kind 10009): the user's joined NIP-29 groups + servers.
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.utils.mapOfSet
val UserMetadataForKeyKinds =
@@ -46,6 +47,7 @@ val UserMetadataForKeyKinds =
ChatMessageRelayListEvent.KIND,
KeyPackageRelayListEvent.KIND,
PaymentTargetsEvent.KIND,
Bolt12OfferListEvent.KIND,
// NIP-61 nutzap-info. Telegraphs which mints + P2PK pubkey this
// user accepts nutzaps at. Co-loaded with kind:0 so the zap-picker
// can decide whether to show the Nutzap chip the moment a note's
@@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapMintTag
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.utils.Hex
import kotlin.concurrent.Volatile
@@ -77,6 +78,8 @@ class User(
val nutzapInfoNote: Note = context.addressableNote(NutzapInfoEvent.createAddress(pubkeyHex))
val bolt12OfferListNote: Note = context.addressableNote(Bolt12OfferListEvent.createAddress(pubkeyHex))
// These objects are designed to keep the cache
// while this user obj is being used anywhere.
//
@@ -113,6 +116,12 @@ class User(
fun nutzapInfo() = nutzapInfoNote.event as? NutzapInfoEvent
/** This user's published BOLT12 offer list (NIP-XX kind 10058), or null if none seen. */
fun bolt12OfferList() = bolt12OfferListNote.event as? Bolt12OfferListEvent
/** The canonical raw BOLT12 offers (`lno1...`) this user accepts, empty when none published. */
fun bolt12Offers(): List<String> = bolt12OfferList()?.offers().orEmpty()
/** True when this user has published a kind:10019 with a P2PK pubkey. */
fun acceptsNutzaps(): Boolean = nutzapInfo()?.p2pkPubkey() != null