refactor(model): lazy-pinned addressable notes on User via UserContext

Pins each per-user replaceable note to the User's lifetime so weak-ref
eviction from LocalCache.addressables can't lose them — same fix the
NIP-65 / DM relay list notes already had, generalised so adding new
pinned kinds is a one-liner.

Background: LocalCache.addressables is a LargeSoftCache<Address,
AddressableNote> backed by WeakReference. Without a strong reference
somewhere, an addressable note shell (and any event loaded into it) can
be cleared on any GC cycle even though it was successfully delivered.
The User constructor already held nip65RelayListNote / dmRelayListNote
fields exactly to defeat this for kinds 10002 and 10050. kind:10019
(NutzapInfoEvent) had no such pin, so the zap picker's "does this user
accept nutzaps?" check would silently return null for an evicted note —
the chip never showed even when the recipient had actually published.

This refactor:
1. Adds `UserContext` — a one-method `fun interface` exposing
   `addressableNote(addr): Note`. User holds it for life; LocalCache
   implements it via a single instance bound to ::getOrCreateAddressableNoteInternal.
2. Converts the three per-user pinned notes (nip65 / dm / nutzapInfo)
   to `by lazy` fields backed by the context. Each is resolved the
   first time it's read and then held by the User's strong reference
   until the User itself is collected. `by lazy`'s default SYNCHRONIZED
   mode handles concurrent reads from the zap picker + wallet state.
3. Adds typed accessors on User: nutzapInfo(), acceptsNutzaps(),
   nutzapMints(), nutzapP2pkPubkey() — mirrors the existing
   authorRelayList() / dmInboxRelayList() shape.
4. CashuWalletState.peekNutzapTarget now reads via
   `cache.getOrCreateUser(recipientPubKey).nutzapInfo()` instead of
   touching the cache's addressable map directly.

Tradeoffs vs the eager-constructor approach:
- No upfront allocation for kinds the screen never reads.
- Adding a new pinned kind (mute list, blocked relays, bookmark list)
  is one `by lazy { context.addressableNote(...) }` line in User —
  no constructor-signature churn across call sites.
- User now depends on a narrow `UserContext` interface; test fakes are
  a one-liner: `User(hex) { addr -> Note(addr.toValue()) }`.

Migration:
- Single User constructor call site (LocalCache.getOrCreateUser) updated.
- Two existing test fakes (NoteOnchainZapTest, SearchResultSorterTest)
  switched to the SAM-lambda form.
- No external behaviour change — the public `nip65RelayListNote` /
  `dmRelayListNote` fields keep the same names and types, so the few
  consumers (RelayFeedViewModel, ChatNewMessageViewModel) need no edits.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
This commit is contained in:
Claude
2026-05-27 15:17:43 +00:00
parent 0cb07761ce
commit 8fa636bbe8
8 changed files with 75 additions and 26 deletions
@@ -536,14 +536,15 @@ object LocalCache : ILocalCache, ICacheProvider {
override fun getOrCreateUser(pubkey: HexKey): User {
require(isValidHex(key = pubkey)) { "$pubkey is not a valid hex" }
return users.getOrCreate(pubkey) {
val nip65RelayListNote = getOrCreateAddressableNoteInternal(AdvertisedRelayListEvent.createAddress(pubkey))
val dmRelayListNote = getOrCreateAddressableNoteInternal(ChatMessageRelayListEvent.createAddress(pubkey))
User(it, nip65RelayListNote, dmRelayListNote)
}
// Pass `this` as the UserContext — User now resolves each pinned
// addressable note (kind:10002 / 10050 / 10019) lazily on first
// read, instead of all-or-nothing at construction time.
return users.getOrCreate(pubkey) { User(it, userContext) }
}
/** [UserContext] bridge to this cache's addressable lookup. */
private val userContext = UserContext(::getOrCreateAddressableNoteInternal)
override fun getUserIfExists(pubkey: String): User? {
if (pubkey.isEmpty()) return null
return users.get(pubkey)
@@ -22,3 +22,5 @@ package com.vitorpamplona.amethyst.model
// Re-export from commons for backwards compatibility
typealias User = com.vitorpamplona.amethyst.commons.model.User
typealias UserContext = com.vitorpamplona.amethyst.commons.model.UserContext
@@ -628,8 +628,11 @@ class CashuWalletState(
val ourMints = _mints.value.toSet()
if (ourMints.isEmpty()) return null
val infoNote = cache.getOrCreateAddressableNote(NutzapInfoEvent.createAddress(recipientPubKey))
val info = infoNote.event as? NutzapInfoEvent ?: return null
// Read the recipient's kind:10019 via their User — User pins the
// addressable note for its own lifetime, so the previous race
// (notes.LargeSoftCache evicts the WeakReference even though the
// event was delivered) no longer drops the chip.
val info = cache.getOrCreateUser(recipientPubKey).nutzapInfo() ?: return null
val recipientPubkeyHex = info.p2pkPubkey() ?: return null
val shared = info.mints().firstOrNull { it.mintUrl in ourMints } ?: return null
@@ -25,10 +25,8 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.Dao
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -41,12 +39,7 @@ import org.junit.Test
class NewMessageTaggerKeyParseTest {
val dao: Dao =
object : Dao {
override suspend fun getOrCreateUser(hex: String): User =
User(
hex,
getOrCreateAddressableNoteInternal(AdvertisedRelayListEvent.createAddress(hex)),
getOrCreateAddressableNoteInternal(ChatMessageRelayListEvent.createAddress(hex)),
)
override suspend fun getOrCreateUser(hex: String): User = User(hex) { addr -> getOrCreateAddressableNoteInternal(addr) }
override suspend fun getOrCreateNote(hex: String) =
com.vitorpamplona.amethyst.model
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusCach
import com.vitorpamplona.amethyst.commons.model.nip56Reports.UserReportCache
import com.vitorpamplona.amethyst.commons.model.trustedAssertions.UserCardsCache
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -35,17 +36,51 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
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.utils.Hex
interface UserDependencies
/**
* Lookup capability the [User] needs from the surrounding cache. Kept
* narrow on purpose only what the lazy pinned-note accessors require,
* so test fakes are a one-liner and User stays decoupled from the full
* `LocalCache` surface.
*/
fun interface UserContext {
fun addressableNote(addr: Address): Note
}
@Stable
class User(
val pubkeyHex: String,
val nip65RelayListNote: Note,
val dmRelayListNote: Note,
private val context: UserContext,
) {
// ============================================================
// Per-user pinned replaceable notes (kind:10002 / 10050 / 10019)
// ============================================================
// Each is resolved lazily on first read and then held by this
// strong-reference field until the User itself is collected. The
// underlying `LocalCache.addressables` map is WeakReference-backed,
// so without these strong refs the note shells (and any event
// loaded into them) could vanish on the next GC even though the
// event was successfully delivered to the cache. Adding a new
// pinned kind is a one-liner here.
val nip65RelayListNote: Note by lazy {
context.addressableNote(AdvertisedRelayListEvent.createAddress(pubkeyHex))
}
val dmRelayListNote: Note by lazy {
context.addressableNote(ChatMessageRelayListEvent.createAddress(pubkeyHex))
}
val nutzapInfoNote: Note by lazy {
context.addressableNote(NutzapInfoEvent.createAddress(pubkeyHex))
}
// These objects are designed to keep the cache
// while this user obj is being used anywhere.
private var metadata: UserMetadataCache? = null
@@ -65,6 +100,17 @@ class User(
fun authorRelayList() = nip65RelayListNote.event as? AdvertisedRelayListEvent
fun nutzapInfo() = nutzapInfoNote.event as? NutzapInfoEvent
/** True when this user has published a kind:10019 with a P2PK pubkey. */
fun acceptsNutzaps(): Boolean = nutzapInfo()?.p2pkPubkey() != null
/** Mints the user has declared accept nutzaps. Empty when no kind:10019. */
fun nutzapMints(): List<NutzapMintTag> = nutzapInfo()?.mints().orEmpty()
/** The recipient P2PK pubkey nutzaps to this user must lock to. */
fun nutzapP2pkPubkey(): String? = nutzapInfo()?.p2pkPubkey()
fun toNProfile() = NProfile.create(pubkeyHex, relayHints())
fun outboxRelays() = authorRelayList()?.writeRelaysNorm()
@@ -41,7 +41,7 @@ class NoteOnchainZapTest {
// must be wired even in unit tests.
private fun sourceNote(pubKey: HexKey): Note {
val src = Note(pubKey)
src.author = User(pubKey, Note(pubKey + "n65"), Note(pubKey + "dm"))
src.author = User(pubKey) { addr -> Note(addr.toValue()) }
return src
}
@@ -72,7 +72,7 @@ class SearchResultSorterTest {
hex: String,
displayName: String,
): User {
val u = User(hex, Note("r1-$hex"), Note("r2-$hex"))
val u = User(hex) { addr -> Note(addr.toValue()) }
val meta = UserMetadata().apply { this.displayName = displayName }
val metaEvent =
MetadataEvent(
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.UserContext
import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
@@ -109,12 +110,15 @@ class DesktopLocalCache : ICacheProvider {
override fun getUserIfExists(pubkey: HexKey): User? = users.get(pubkey)
override fun getOrCreateUser(pubkey: HexKey): User =
users.getOrCreate(pubkey) {
val nip65Note = getOrCreateNote("nip65:$pubkey")
val dmNote = getOrCreateNote("dm:$pubkey")
User(pubkey, nip65Note, dmNote)
}
override fun getOrCreateUser(pubkey: HexKey): User = users.getOrCreate(pubkey) { User(pubkey, userContext) }
/**
* [UserContext] bridge desktop's note store is keyed on string ids
* rather than full addressable maps, so we synthesise a stable id
* from the Address. User's lazy fields hold the resulting Note for
* its lifetime, same pinning guarantee as on Android.
*/
private val userContext = UserContext { addr -> getOrCreateNote(addr.toValue()) }
override fun countUsers(predicate: (String, User) -> Boolean): Int = users.count { key, user -> predicate(key, user) }