diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 43bb84c9a2..ef75600b76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -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(latestKeyPackageRelayListStr) } val latestFavoriteAlgoFeedsList = async { parseEventOrNull(latestFavoriteAlgoFeedsListStr) } val latestPaymentTargets = async { parseEventOrNull(latestPaymentTargetsStr) } + val latestBolt12Offers = async { parseEventOrNull(latestBolt12OffersStr) } val latestCashuWallet = async { parseEventOrNull(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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index a8ba363c3d..4ce4e79953 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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) = sendMyPublicAndPrivateOutbox(paymentTargetsState.savePaymentTargets(targets)) + suspend fun saveBolt12Offers(offers: List) = sendMyPublicAndPrivateOutbox(bolt12OfferList.saveOffers(offers)) + fun markAsRead( route: String, timestampInSecs: Long, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 9ffc3e7d31..cf62e2454c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -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> = MutableStateFlow(mapOf()), val pendingAttestations: MutableStateFlow> = MutableStateFlow(mapOf()), var backupNipA3PaymentTargets: PaymentTargetsEvent? = null, + var backupBolt12Offers: Bolt12OfferListEvent? = null, var callTurnServers: List = 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index d203316fb8..01b4a46a1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/bolt12Offers/Bolt12OfferListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/bolt12Offers/Bolt12OfferListState.kt new file mode 100644 index 0000000000..d32b024393 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/bolt12Offers/Bolt12OfferListState.kt @@ -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 = 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> = + getBolt12OfferListFlow() + .map { (it.note.event as? Bolt12OfferListEvent)?.offers() ?: emptyList() } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + + suspend fun saveOffers(offers: List): 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) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index ae7323972e..8299f85493 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt index 6de480a111..294ca849df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt @@ -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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt index d9787fd2cd..9b48c1555c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt @@ -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 = bolt12OfferList()?.offers().orEmpty() + /** True when this user has published a kind:10019 with a P2PK pubkey. */ fun acceptsNutzaps(): Boolean = nutzapInfo()?.p2pkPubkey() != null