Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ extension NostrNetworkManager {

/// Relay filters
var relayFilters: RelayFilters { get }

/// The user's connected NWC wallet
var nwcWallet: WalletConnectURL? { get }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,12 @@ extension NostrNetworkManager {
}

private func computeRelaysToConnectTo(with relayList: NIP65.RelayList) -> [RelayPool.RelayDescriptor] {
let regularRelayDescriptorList = relayList.toRelayDescriptors()
var descriptors = relayList.toRelayDescriptors()

if let nwcWallet = delegate.nwcWallet {
return regularRelayDescriptorList + [.nwc(url: nwcWallet.relay)]
descriptors.append(.nwc(url: nwcWallet.relay))
}
Comment on lines 40 to 42
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Potential duplicate relay with conflicting variants.

If a user manually adds their NWC relay URL to their relay list, it will be created with variant .regular (line 264). This code then appends the same URL again with variant .nwc. When apply() processes the list and calls first(where: { $0.url == url }) (line 230), it will select the first descriptor with that URL—likely the .regular variant—causing the NWC relay to be misconfigured.

🔎 Proposed fix to prevent duplicate relay URLs
 private func computeRelaysToConnectTo(with relayList: NIP65.RelayList) -> [RelayPool.RelayDescriptor] {
     var descriptors = relayList.toRelayDescriptors()
 
     if let nwcWallet = delegate.nwcWallet {
-        descriptors.append(.nwc(url: nwcWallet.relay))
+        // Remove any existing descriptor with the NWC relay URL to avoid conflicts
+        descriptors.removeAll(where: { $0.url == nwcWallet.relay })
+        descriptors.append(.nwc(url: nwcWallet.relay))
     }
     return descriptors
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if let nwcWallet = delegate.nwcWallet {
return regularRelayDescriptorList + [.nwc(url: nwcWallet.relay)]
descriptors.append(.nwc(url: nwcWallet.relay))
}
if let nwcWallet = delegate.nwcWallet {
// Remove any existing descriptor with the NWC relay URL to avoid conflicts
descriptors.removeAll(where: { $0.url == nwcWallet.relay })
descriptors.append(.nwc(url: nwcWallet.relay))
}
🤖 Prompt for AI Agents
In @damus/Core/Networking/NostrNetworkManager/UserRelayListManager.swift around
lines 40-42, The appended .nwc descriptor can duplicate an existing descriptor
with the same URL (often added as .regular), causing apply()'s first(where: {
$0.url == url }) to pick the wrong variant; before appending the nwc descriptor
in the block that reads delegate.nwcWallet, check descriptors for an existing
descriptor with the same relay URL and either replace/update its variant to .nwc
(or remove the existing and append the .nwc one) so the list contains a single
entry per URL; this ensures apply() will match the intended .nwc variant rather
than a conflicting .regular entry.

return regularRelayDescriptorList
return descriptors
}

// MARK: - Getting the user's relay list
Expand Down
19 changes: 18 additions & 1 deletion damus/Core/Nostr/NostrRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,28 @@ enum NostrRequestType {
guard case .typical(let req) = self else {
return true
}

return req.is_read
}
}

extension NostrRequestType {
/// Profile-related kinds that should be queried on profiles-only relays
static let profileKinds: Set<NostrKind> = [.metadata, .contacts, .relay_list]

/// Whether this request is for profile-related data only
var isProfileRelated: Bool {
guard case .typical(let req) = self else { return false }
guard case .subscribe(let sub) = req else { return false }

// Check if ALL filters contain ONLY profile-related kinds
return sub.filters.allSatisfy { filter in
guard let kinds = filter.kinds else { return false } // No kinds specified = could be anything
return kinds.allSatisfy { Self.profileKinds.contains($0) }
}
}
Comment on lines +46 to +55
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard against edge cases with empty filter or kinds arrays.

The allSatisfy method returns true for empty collections (vacuous truth), which could lead to incorrect classification:

  1. Empty filters array: If sub.filters is empty, the function returns true, treating it as profile-related. However, a subscription with no filters would conceptually match all events and should not be sent to a profile-only relay.

  2. Empty kinds array: If filter.kinds is an empty array (rather than nil), the inner allSatisfy returns true, incorrectly treating it as profile-related. An empty kinds array typically means no kind restriction.

These edge cases could cause non-profile queries to be sent to purplepag.es, defeating the purpose of this filtering.

🔎 Proposed fix to handle edge cases
     /// Whether this request is for profile-related data only
     var isProfileRelated: Bool {
         guard case .typical(let req) = self else { return false }
         guard case .subscribe(let sub) = req else { return false }
-
+        
+        // Must have at least one filter
+        guard !sub.filters.isEmpty else { return false }
+        
         // Check if ALL filters contain ONLY profile-related kinds
         return sub.filters.allSatisfy { filter in
             guard let kinds = filter.kinds else { return false }  // No kinds specified = could be anything
+            guard !kinds.isEmpty else { return false }  // Empty kinds = could be anything
             return kinds.allSatisfy { Self.profileKinds.contains($0) }
         }
     }
🤖 Prompt for AI Agents
In @damus/Core/Nostr/NostrRequest.swift around lines 46-55, The isProfileRelated
logic incorrectly treats empty filters or empty kinds as profile-only due to
vacuous truth; update the check in NostrRequest.isProfileRelated (the guard for
.typical -> .subscribe and the return using sub.filters.allSatisfy) to first
return false if sub.filters.isEmpty, and inside the allSatisfy closure require
that filter.kinds is non-nil AND not empty (e.g., guard let kinds =
filter.kinds, !kinds.isEmpty else { return false }) before checking
kinds.allSatisfy { Self.profileKinds.contains($0) } so empty filters or empty
kinds do not get classified as profile-related.

}

/// Models a standard request/message that is sent to a Nostr relay.
enum NostrRequest {
/// Subscribes to receive information from the relay
Expand Down
13 changes: 12 additions & 1 deletion damus/Core/Nostr/Relay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ enum RelayVariant {
case regular
case ephemeral
case nwc
case profilesOnly // Only used for profile metadata queries (kind 0, 3, 10002)
}

extension RelayPool {
Expand All @@ -55,12 +56,22 @@ extension RelayPool {
return true
case .nwc:
return true
case .profilesOnly:
return false // Not ephemeral - filtering is handled separately via isProfileRelated check
}
}


var isProfilesOnly: Bool {
variant == .profilesOnly
}

static func nwc(url: RelayURL) -> RelayDescriptor {
return RelayDescriptor(url: url, info: .readWrite, variant: .nwc)
}

static func profilesOnly(url: RelayURL) -> RelayDescriptor {
return RelayDescriptor(url: url, info: .read, variant: .profilesOnly)
}
}
}

Expand Down
25 changes: 14 additions & 11 deletions damus/Core/Nostr/RelayPool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,13 @@ actor RelayPool {
if relay.descriptor.ephemeral && skip_ephemeral {
continue // Do not send requests to ephemeral relays if we want to skip them
}


// Filter purplepag.es to only receive profile-related requests (kinds 0, 3, 10002)
let isPurplePagesRelay = relay.descriptor.url.absoluteString.contains("purplepag.es")
if (relay.descriptor.isProfilesOnly || isPurplePagesRelay) && !req.isProfileRelated {
continue
}

guard relay.connection.isConnected else {
Task { await queue_req(r: req, relay: relay.id, skip_ephemeral: skip_ephemeral) }
continue
Expand Down Expand Up @@ -465,16 +471,13 @@ actor RelayPool {
}

func record_seen(relay_id: RelayURL, event: NostrConnectionEvent) {
if case .nostr_event(let ev) = event {
if case .event(_, let nev) = ev {
if seen[nev.id]?.contains(relay_id) == true {
return
}
seen[nev.id, default: Set()].insert(relay_id)
counts[relay_id, default: 0] += 1
notify(.update_stats(note_id: nev.id))
}
}
guard case .nostr_event(let ev) = event else { return }
guard case .event(_, let nev) = ev else { return }
guard seen[nev.id]?.contains(relay_id) != true else { return }

seen[nev.id, default: Set()].insert(relay_id)
counts[relay_id, default: 0] += 1
notify(.update_stats(note_id: nev.id))
}

func resubscribeAll(relayId: RelayURL) async {
Expand Down
1 change: 1 addition & 0 deletions damus/Features/Relays/Models/RelayBootstrap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ fileprivate let BOOTSTRAP_RELAYS = [
"wss://nostr.land",
"wss://nostr.wine",
"wss://nos.lol",
"wss://purplepag.es",
]

fileprivate let REGION_SPECIFIC_BOOTSTRAP_RELAYS: [Locale.Region: [String]] = [
Expand Down
9 changes: 3 additions & 6 deletions damus/Features/Relays/Views/RelayConfigView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,8 @@ struct RelayConfigView: View {
}

var recommended: [RelayPool.RelayDescriptor] {
let rs: [RelayPool.RelayDescriptor] = []
let recommended_relay_addresses = get_default_bootstrap_relays()
return recommended_relay_addresses.reduce(into: rs) { xs, x in
xs.append(RelayPool.RelayDescriptor(url: x, info: .readWrite))
}
// Use default bootstrap relays (includes purplepag.es)
return get_default_bootstrap_relays().map { RelayPool.RelayDescriptor(url: $0, info: .readWrite) }
}

var body: some View {
Expand Down Expand Up @@ -134,7 +131,7 @@ struct RelayConfigView: View {

ForEach(relayList, id: \.url) { relay in
Group {
RelayView(state: state, relay: relay.url, showActionButtons: $showActionButtons, recommended: recommended)
RelayView(state: state, relay: relay.url, showActionButtons: $showActionButtons, recommended: recommended, descriptor: relay)
Divider()
}
}
Expand Down
33 changes: 24 additions & 9 deletions damus/Features/Relays/Views/RelayDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,25 @@ struct RelayDetailView: View {
self.state = state
self.relay = relay
self.nip11 = nip11

log = state.relay_model_cache.model(with_relay_id: relay)?.log ?? RelayLog()
}

func check_connection() -> Bool {
return state.nostrNetwork.userRelayList.getUserCurrentRelayList()?.relays.keys.contains(self.relay) == true

/// Whether relay is connected (in the user's relay list)
var isConnected: Bool {
state.nostrNetwork.userRelayList.getUserCurrentRelayList()?.relays.keys.contains(self.relay) == true
}

/// Check if relay URL is purplepag.es (for showing Profile badge)
static func isPurplePagesRelay(_ relay: RelayURL) -> Bool {
relay.absoluteString.contains("purplepag.es")
}

/// Whether this relay is profile-only (for showing Profile badge)
var isProfileOnlyRelay: Bool {
if Self.isPurplePagesRelay(relay) {
return true
}
return state.nostrNetwork.getRelay(relay)?.descriptor.isProfilesOnly ?? false
}

func RemoveRelayButton(_ keypair: FullKeypair) -> some View {
Expand Down Expand Up @@ -87,10 +100,14 @@ struct RelayDetailView: View {
.foregroundColor(.gray)

HStack {
if isProfileOnlyRelay {
RelayType(is_paid: false, is_profile_only: true)
}

if nip11?.is_paid ?? false {
RelayPaidDetail(payments_url: nip11?.payments_url, fees: nip11?.fees)
}

if let authentication_state: RelayAuthenticationState = relay_object?.authentication_state,
authentication_state != .none {
RelayAuthenticationDetail(state: authentication_state)
Expand Down Expand Up @@ -138,7 +155,7 @@ struct RelayDetailView: View {
}

if let keypair = state.keypair.to_full() {
if check_connection() {
if isConnected {
RemoveRelayButton(keypair)
.padding(.top)
} else {
Expand Down Expand Up @@ -187,18 +204,16 @@ struct RelayDetailView: View {

func removeRelay() async {
do {
// TODO: Concurrency problems?
try await state.nostrNetwork.userRelayList.remove(relayURL: self.relay)
dismiss()
}
catch {
present_sheet(.error(error.humanReadableError))
}
}

func connectRelay() async {
do {
// TODO: Concurrency problems?
try await state.nostrNetwork.userRelayList.insert(relay: NIP65.RelayList.RelayItem(url: relay, rwConfiguration: .readWrite))
dismiss()
}
Expand Down
21 changes: 18 additions & 3 deletions damus/Features/Relays/Views/RelayType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,23 @@ import SwiftUI

struct RelayType: View {
let is_paid: Bool

var is_profile_only: Bool = false

var body: some View {
if is_paid {
Image("bitcoin-logo")
HStack(spacing: 4) {
if is_paid {
Image("bitcoin-logo")
}
if is_profile_only {
Text("Profile")
.font(.caption2)
.fontWeight(.medium)
.foregroundColor(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(DamusColors.purple)
.cornerRadius(4)
}
}
}
}
Expand All @@ -22,6 +35,8 @@ struct RelayType_Previews: PreviewProvider {
VStack {
RelayType(is_paid: false)
RelayType(is_paid: true)
RelayType(is_paid: false, is_profile_only: true)
RelayType(is_paid: true, is_profile_only: true)
}
}
}
39 changes: 29 additions & 10 deletions damus/Features/Relays/Views/RelayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,40 @@ struct RelayView: View {
let recommended: Bool
/// Disables navigation link
let disableNavLink: Bool
/// Optional descriptor for recommended relays (not yet connected)
let descriptor: RelayPool.RelayDescriptor?
@ObservedObject private var model_cache: RelayModelCache

@State var relay_state: Bool
@Binding var showActionButtons: Bool

init(state: DamusState, relay: RelayURL, showActionButtons: Binding<Bool>, recommended: Bool, disableNavLink: Bool = false) {
init(state: DamusState, relay: RelayURL, showActionButtons: Binding<Bool>, recommended: Bool, disableNavLink: Bool = false, descriptor: RelayPool.RelayDescriptor? = nil) {
self.state = state
self.relay = relay
self.recommended = recommended
self.model_cache = state.relay_model_cache
_showActionButtons = showActionButtons
let relay_state = RelayView.get_relay_state(state: state, relay: relay)
self._relay_state = State(initialValue: relay_state)
self._relay_state = State(initialValue: state.nostrNetwork.getRelay(relay) == nil)
self.disableNavLink = disableNavLink
self.descriptor = descriptor
}

static func get_relay_state(state: DamusState, relay: RelayURL) -> Bool {
return state.nostrNetwork.getRelay(relay) == nil
/// Check if relay URL is purplepag.es (for showing Profile badge)
static func isPurplePagesRelay(_ relay: RelayURL) -> Bool {
relay.absoluteString.contains("purplepag.es")
}

/// Whether this relay is profile-only (for showing Profile badge)
var isProfileOnly: Bool {
if Self.isPurplePagesRelay(relay) {
return true
}
return state.nostrNetwork.getRelay(relay)?.descriptor.isProfilesOnly ?? descriptor?.isProfilesOnly ?? false
}

/// Whether relay needs to be added (true = show Add button, false = show Added button)
var needsToBeAdded: Bool {
return relay_state
}

var body: some View {
Expand All @@ -52,8 +68,11 @@ struct RelayView: View {
.font(.headline)
.padding(.bottom, 2)
.lineLimit(1)
RelayType(is_paid: state.relay_model_cache.model(with_relay_id: relay)?.metadata.is_paid ?? false)

RelayType(
is_paid: state.relay_model_cache.model(with_relay_id: relay)?.metadata.is_paid ?? false,
is_profile_only: isProfileOnly
)

if relay.absoluteString.hasSuffix(".onion") {
Image("tor")
.resizable()
Expand All @@ -79,7 +98,7 @@ struct RelayView: View {
if recommended {
if let keypair = state.keypair.to_full() {
VStack(alignment: .center) {
if relay_state {
if needsToBeAdded {
AddButton(keypair: keypair)
} else {
Button(action: {
Expand Down Expand Up @@ -110,7 +129,7 @@ struct RelayView: View {
.contentShape(Rectangle())
}
.onReceive(handle_notify(.relays_changed)) { _ in
self.relay_state = RelayView.get_relay_state(state: state, relay: self.relay)
self.relay_state = state.nostrNetwork.getRelay(relay) == nil
}
.onTapGesture {
if !disableNavLink {
Expand All @@ -131,7 +150,7 @@ struct RelayView: View {
present_sheet(.error(error.humanReadableError))
}
}

func remove_action(privkey: Privkey) async {
do {
try await state.nostrNetwork.userRelayList.remove(relayURL: relay)
Expand Down
2 changes: 1 addition & 1 deletion damus/Features/Settings/Models/UserSettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ class UserSettingsStore: ObservableObject {
/// Whether the app has the experimental local relay model flag that streams data only from the local relay (ndb)
@Setting(key: "enable_experimental_local_relay_model", default_value: false)
var enable_experimental_local_relay_model: Bool

/// Whether the app should present the experimental floating "Load new content" button
@Setting(key: "enable_experimental_load_new_content_button", default_value: false)
var enable_experimental_load_new_content_button: Bool
Expand Down