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:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user