Redesign Ndb.swift interface with build safety

This commit redesigns the Ndb.swift interface with a focus on build-time
safety against crashes.

It removes the external usage of NdbTxn and SafeNdbTxn, restricting it
to be used only in NostrDB internal code.

This prevents dangerous and crash prone usages throughout the app, such
as holding transactions in a variable in an async function (which can
cause thread-based reference counting to incorrectly deinit inherited
transactions in use by separate callers), as well as holding unsafe
unowned values longer than the lifetime of their corresponding
transactions.

Closes: https://github.com/damus-io/damus/issues/3364
Changelog-Fixed: Fixed several crashes throughout the app
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
Daniel D’Aquino
2025-11-28 19:17:35 -08:00
parent b562b930cc
commit f844ed9931
60 changed files with 611 additions and 497 deletions

View File

@@ -96,7 +96,7 @@ extension NostrNetworkManager {
if let relevantStreams = streams[metadataEvent.pubkey] {
// If we have the user metadata event in ndb, then we should have the profile record as well.
guard let profile = ndb.lookup_profile(metadataEvent.pubkey) else { return }
guard let profile = ndb.lookup_profile_and_copy(metadataEvent.pubkey) else { return }
for relevantStream in relevantStreams.values {
relevantStream.continuation.yield(profile)
}
@@ -144,7 +144,7 @@ extension NostrNetworkManager {
// MARK: - Helper types
typealias ProfileStreamItem = NdbTxn<ProfileRecord?>
typealias ProfileStreamItem = Profile
struct ProfileStreamInfo {
let id: UUID = UUID()

View File

@@ -413,15 +413,18 @@ extension NostrNetworkManager {
switch query {
case .profile(let pubkey):
if let profile_txn = self.ndb.lookup_profile(pubkey),
let record = profile_txn.unsafeUnownedValue,
record.profile != nil
{
let profileNotNil = self.ndb.lookup_profile(pubkey, borrow: { pr in
switch pr {
case .some(let pr): return pr.profile != nil
case .none: return true
}
})
if profileNotNil {
return .profile(pubkey)
}
filter = NostrFilter(kinds: [.metadata], limit: 1, authors: [pubkey])
case .event(let evid):
if let event = self.ndb.lookup_note(evid)?.unsafeUnownedValue?.to_owned() {
if let event = self.ndb.lookup_note_and_copy(evid) {
return .event(event)
}
filter = NostrFilter(ids: [evid], limit: 1)

View File

@@ -87,7 +87,7 @@ extension NostrNetworkManager {
private func getLatestNIP65RelayListEvent() -> NdbNote? {
guard let latestRelayListEventId = delegate.latestRelayListEventIdHex else { return nil }
guard let latestRelayListEventId = NoteId(hex: latestRelayListEventId) else { return nil }
return delegate.ndb.lookup_note(latestRelayListEventId)?.unsafeUnownedValue?.to_owned()
return delegate.ndb.lookup_note_and_copy(latestRelayListEventId)
}
/// Gets the latest `kind:3` relay list from NostrDB.

View File

@@ -11,8 +11,8 @@ typealias Profile = NdbProfile
typealias ProfileKey = UInt64
//typealias ProfileRecord = NdbProfileRecord
class ProfileRecord {
let data: NdbProfileRecord
struct ProfileRecord: ~Copyable {
private let data: NdbProfileRecord // Marked as private to make users access the safer `profile` property
init(data: NdbProfileRecord, key: ProfileKey) {
self.data = data
@@ -20,7 +20,11 @@ class ProfileRecord {
}
let profileKey: ProfileKey
var profile: Profile? { return data.profile }
var profile: Profile? {
// Clone the data since `NdbProfile` can be unowned, but does not `~Copyable` semantics.
// This helps ensure the memory safety of this property
return data.profile?.clone()
}
var receivedAt: UInt64 { data.receivedAt }
var noteKey: UInt64 { data.noteKey }
@@ -37,10 +41,7 @@ class ProfileRecord {
}
if addr.contains("@") {
// this is a heavy op and is used a lot in views, cache it!
let addr = lnaddress_to_lnurl(addr);
self._lnurl = addr
return addr
return lnaddress_to_lnurl(addr)
}
if !addr.lowercased().hasPrefix("lnurl") {
@@ -81,6 +82,24 @@ extension NdbProfile {
return URL(string: trim)
}
}
/// Clones this object. Useful for creating an owned copy from an unowned profile
func clone() -> Self {
return NdbProfile(
name: self.name,
display_name: self.display_name,
about: self.about,
picture: self.picture,
banner: self.banner,
website: self.website,
lud06: self.lud06,
lud16: self.lud16,
nip05: self.nip05,
damus_donation: self.damus_donation,
reactions: self.reactions
)
}
init(name: String? = nil, display_name: String? = nil, about: String? = nil, picture: String? = nil, banner: String? = nil, website: String? = nil, lud06: String? = nil, lud16: String? = nil, nip05: String? = nil, damus_donation: Int? = nil, reactions: Bool = true) {
@@ -309,7 +328,40 @@ func make_ln_url(_ str: String?) -> URL? {
return str.flatMap { URL(string: "lightning:" + $0) }
}
import Synchronization
@available(iOS 18.0, *)
class CachedLNAddressConverter {
static let shared: CachedLNAddressConverter = .init()
private let cache: Mutex<[String: String?]> = .init([:]) // Using a mutex here to avoid race conditions without imposing actor isolation requirements.
func lnaddress_to_lnurl(_ lnaddr: String) -> String? {
if let cachedValue = cache.withLock({ $0[lnaddr] }) {
return cachedValue
}
let lnurl: String? = compute_lnaddress_to_lnurl(lnaddr)
cache.withLock({ cache in
cache[lnaddr] = .some(lnurl)
})
return lnurl
}
}
func lnaddress_to_lnurl(_ lnaddr: String) -> String? {
if #available(iOS 18.0, *) {
// This is a heavy op, use a cache if available!
return CachedLNAddressConverter.shared.lnaddress_to_lnurl(lnaddr)
} else {
// Fallback on earlier versions
return compute_lnaddress_to_lnurl(lnaddr)
}
}
func compute_lnaddress_to_lnurl(_ lnaddr: String) -> String? {
let parts = lnaddr.split(separator: "@")
guard parts.count == 2 else {
return nil
@@ -322,4 +374,3 @@ func lnaddress_to_lnurl(_ lnaddr: String) -> String? {
return bech32_encode(hrp: "lnurl", Array(dat))
}

View File

@@ -810,42 +810,41 @@ func validate_event(ev: NostrEvent) -> ValidationResult {
}
func first_eref_mention(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> Mention<NoteId>? {
guard let blockGroup = try? NdbBlockGroup.from(event: ev, using: ndb, and: keypair) else { return nil }
return blockGroup.forEachBlock({ index, block in
switch block {
case .mention(let mention):
guard let mention = MentionRef(block: mention) else { return .loopContinue }
switch mention.nip19 {
case .note(let noteId):
return .loopReturn(Mention<NoteId>.note(noteId, index: index))
case .nevent(let nEvent):
return .loopReturn(Mention<NoteId>.note(nEvent.noteid, index: index))
return try? NdbBlockGroup.borrowBlockGroup(event: ev, using: ndb, and: keypair, borrow: { blockGroup in
return blockGroup.forEachBlock({ index, block in
switch block {
case .mention(let mention):
guard let mention = MentionRef(block: mention) else { return .loopContinue }
switch mention.nip19 {
case .note(let noteId):
return .loopReturn(Mention<NoteId>.note(noteId, index: index))
case .nevent(let nEvent):
return .loopReturn(Mention<NoteId>.note(nEvent.noteid, index: index))
default:
return .loopContinue
}
default:
return .loopContinue
}
default:
return .loopContinue
}
})
})
}
func separate_invoices(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> [Invoice]? {
guard let blockGroup = try? NdbBlockGroup.from(event: ev, using: ndb, and: keypair) else {
return nil
}
let invoiceBlocks: [Invoice] = (try? blockGroup.reduce(initialResult: [Invoice](), { index, invoices, block in
switch block {
case .invoice(let invoice):
if let invoice = invoice.as_invoice() {
return .loopReturn(invoices + [invoice])
return try? NdbBlockGroup.borrowBlockGroup(event: ev, using: ndb, and: keypair, borrow: { blockGroup in
let invoiceBlocks: [Invoice] = (try? blockGroup.reduce(initialResult: [Invoice](), { index, invoices, block in
switch block {
case .invoice(let invoice):
if let invoice = invoice.as_invoice() {
return .loopReturn(invoices + [invoice])
}
default:
break
}
default:
break
}
return .loopContinue
})) ?? []
return invoiceBlocks.isEmpty ? nil : invoiceBlocks
return .loopContinue
})) ?? []
return invoiceBlocks.isEmpty ? nil : invoiceBlocks
})
}
/**

View File

@@ -74,31 +74,45 @@ class Profiles {
profile_data(pubkey).zapper
}
func lookup_with_timestamp(_ pubkey: Pubkey) -> NdbTxn<ProfileRecord?>? {
ndb.lookup_profile(pubkey)
func lookup_with_timestamp<T>(_ pubkey: Pubkey, borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) rethrows -> T {
return try ndb.lookup_profile(pubkey, borrow: lendingFunction)
}
func lookup_lnurl(_ pubkey: Pubkey) -> String? {
return lookup_with_timestamp(pubkey, borrow: { pr in
switch pr {
case .some(let pr): return pr.lnurl
case .none: return nil
}
})
}
func lookup_by_key(key: ProfileKey) -> NdbTxn<ProfileRecord?>? {
ndb.lookup_profile_by_key(key: key)
func lookup_by_key<T>(key: ProfileKey, borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) rethrows -> T {
return try ndb.lookup_profile_by_key(key: key, borrow: lendingFunction)
}
func search<Y>(_ query: String, limit: Int, txn: NdbTxn<Y>) -> [Pubkey] {
ndb.search_profile(query, limit: limit, txn: txn)
func search(_ query: String, limit: Int) -> [Pubkey] {
ndb.search_profile(query, limit: limit)
}
func lookup(id: Pubkey, txn_name: String? = nil) -> NdbTxn<Profile?>? {
guard let txn = ndb.lookup_profile(id, txn_name: txn_name) else {
return nil
}
return txn.map({ pr in pr?.profile })
func lookup(id: Pubkey) -> Profile? {
return ndb.lookup_profile(id, borrow: { pr in
switch pr {
case .none:
return nil
case .some(let profileRecord):
// This will clone the value to make it owned and safe to return.
return profileRecord.profile
}
})
}
func lookup_key_by_pubkey(_ pubkey: Pubkey) -> ProfileKey? {
ndb.lookup_profile_key(pubkey)
}
func has_fresh_profile<Y>(id: Pubkey, txn: NdbTxn<Y>) -> Bool {
guard let fetched_at = ndb.read_profile_last_fetched(txn: txn, pubkey: id)
func has_fresh_profile(id: Pubkey) -> Bool {
guard let fetched_at = ndb.read_profile_last_fetched(pubkey: id)
else {
return false
}