diff --git a/Clave/AppState.swift b/Clave/AppState.swift index 2c375af..b27ce10 100644 --- a/Clave/AppState.swift +++ b/Clave/AppState.swift @@ -285,7 +285,12 @@ final class AppState { } do { - let result = try await LightSigner.handleRequest(privateKey: privateKey, requestEvent: requestEvent, skipProtection: true) + let result = try await LightSigner.handleRequest( + privateKey: privateKey, + requestEvent: requestEvent, + skipProtection: true, + responseRelayUrl: request.responseRelayUrl + ) SharedStorage.removePendingRequest(id: request.id) refreshPendingRequests() return result.status == "signed" diff --git a/Clave/ClaveApp.swift b/Clave/ClaveApp.swift index b774d71..3a98b0e 100644 --- a/Clave/ClaveApp.swift +++ b/Clave/ClaveApp.swift @@ -103,32 +103,46 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele let relayUrlString = (userInfo["relay_url"] as? String) ?? SharedConstants.relayURL do { - let relay = LightRelay(url: relayUrlString) - try await relay.connect() - - let now = Int(Date().timeIntervalSince1970) - // NIP-46 spec: clients MUST include ["p", ] on kind:24133 - let filter: [String: Any] = [ - "kinds": [24133], - "#p": [signerPubkey], - "since": now - 60, - "limit": 5 - ] - - var events = try await relay.fetchEvents(filter: filter, timeout: 10.0) - - if events.isEmpty { - try await Task.sleep(nanoseconds: 2_000_000_000) - let retryFilter: [String: Any] = [ + var events: [[String: Any]] = [] + + // Prefer embedded event from the push payload. See NotificationService.swift + // for rationale — same fix, same race, same fallback. + if let embedded = userInfo["event"] as? [String: Any] { + logger.notice("[App] Using embedded event from push payload") + events = [embedded] + } else { + if userInfo["event"] != nil { + logger.warning("[App] event key present but not castable to [String: Any] — falling back to relay fetch") + } else { + logger.notice("[App] No embedded event; fetching from \(relayUrlString, privacy: .public)") + } + let relay = LightRelay(url: relayUrlString) + try await relay.connect() + + let now = Int(Date().timeIntervalSince1970) + // NIP-46 spec: clients MUST include ["p", ] on kind:24133 + let filter: [String: Any] = [ "kinds": [24133], "#p": [signerPubkey], - "since": now - 120, + "since": now - 60, "limit": 5 ] - events = try await relay.fetchEvents(filter: retryFilter, timeout: 10.0) - } - relay.disconnect() + events = try await relay.fetchEvents(filter: filter, timeout: 10.0) + + if events.isEmpty { + try await Task.sleep(nanoseconds: 2_000_000_000) + let retryFilter: [String: Any] = [ + "kinds": [24133], + "#p": [signerPubkey], + "since": now - 120, + "limit": 5 + ] + events = try await relay.fetchEvents(filter: retryFilter, timeout: 10.0) + } + + relay.disconnect() + } let processedKey = "processedEventIDs" var processedIDs = Set(SharedConstants.sharedDefaults.stringArray(forKey: processedKey) ?? []) diff --git a/ClaveNSE/NotificationService.swift b/ClaveNSE/NotificationService.swift index 216c090..56c51e0 100644 --- a/ClaveNSE/NotificationService.swift +++ b/ClaveNSE/NotificationService.swift @@ -110,32 +110,48 @@ class NotificationService: UNNotificationServiceExtension { let relayUrlString = (userInfo["relay_url"] as? String) ?? SharedConstants.relayURL do { - let relay = LightRelay(url: relayUrlString) - try await relay.connect() - - let now = Int(Date().timeIntervalSince1970) - // NIP-46 spec: clients MUST include ["p", ] on kind:24133 - let filter: [String: Any] = [ - "kinds": [24133], - "#p": [signerPubkey], - "since": now - 60, - "limit": 5 - ] - - var events = try await relay.fetchEvents(filter: filter, timeout: 10.0) + var events: [[String: Any]] = [] + + // Prefer the embedded event from the push payload (build 22+). This + // bypasses the ephemeral-fetch race where the relay drops kind:24133 + // before NSE can REQ for it. If absent (older proxy or oversized + // event), fall through to the existing fetch-from-relay path. + if let embedded = userInfo["event"] as? [String: Any] { + logger.notice("[ClaveNSE] Using embedded event from push payload") + events = [embedded] + } else { + if userInfo["event"] != nil { + logger.warning("[ClaveNSE] event key present but not castable to [String: Any] — falling back to relay fetch") + } else { + logger.notice("[ClaveNSE] No embedded event; fetching from \(relayUrlString, privacy: .public)") + } + let relay = LightRelay(url: relayUrlString) + try await relay.connect() - if events.isEmpty { - try await Task.sleep(nanoseconds: 2_000_000_000) - let retryFilter: [String: Any] = [ + let now = Int(Date().timeIntervalSince1970) + // NIP-46 spec: clients MUST include ["p", ] on kind:24133 + let filter: [String: Any] = [ "kinds": [24133], "#p": [signerPubkey], - "since": now - 120, + "since": now - 60, "limit": 5 ] - events = try await relay.fetchEvents(filter: retryFilter, timeout: 10.0) - } - relay.disconnect() + events = try await relay.fetchEvents(filter: filter, timeout: 10.0) + + if events.isEmpty { + try await Task.sleep(nanoseconds: 2_000_000_000) + let retryFilter: [String: Any] = [ + "kinds": [24133], + "#p": [signerPubkey], + "since": now - 120, + "limit": 5 + ] + events = try await relay.fetchEvents(filter: retryFilter, timeout: 10.0) + } + + relay.disconnect() + } if events.isEmpty { return SigningResult(status: .noEvents) diff --git a/Shared/LightSigner.swift b/Shared/LightSigner.swift index 28cb374..31eaf79 100644 --- a/Shared/LightSigner.swift +++ b/Shared/LightSigner.swift @@ -197,7 +197,8 @@ enum LightSigner { method: method, eventKind: eventKind ?? 0, clientPubkey: senderPubkey, - timestamp: Date().timeIntervalSince1970 + timestamp: Date().timeIntervalSince1970, + responseRelayUrl: responseRelayUrl ) SharedStorage.queuePendingRequest(pending) } diff --git a/Shared/SharedModels.swift b/Shared/SharedModels.swift index 87e1ba5..b3dacc5 100644 --- a/Shared/SharedModels.swift +++ b/Shared/SharedModels.swift @@ -18,6 +18,16 @@ struct PendingRequest: Codable, Identifiable { let eventKind: Int? let clientPubkey: String let timestamp: Double + /// Relay URL the request was received on. Threaded back into + /// LightSigner.handleRequest at approval-time so the response publishes + /// to the same relay the client is actually subscribed on — not the + /// powr.build fallback. + /// + /// Optional for backward compatibility — Codable decodes missing keys on + /// Optional properties as nil without throwing, so pre-build-22 rows in + /// UserDefaults decode cleanly and fall back to SharedConstants.relayURL + /// at publish time. + let responseRelayUrl: String? } struct ConnectedClient: Codable, Identifiable { diff --git a/relay-proxy/proxy.js b/relay-proxy/proxy.js index 21403b9..b9acf74 100644 --- a/relay-proxy/proxy.js +++ b/relay-proxy/proxy.js @@ -575,6 +575,20 @@ async function dispatchCaughtEvent({ event, sourceUrl, classification }) { event_id: event.id, }; + // Embed the caught event so NSE doesn't have to race the relay's ephemeral + // retention window. APNs alert payload cap is 4KB; 3500B leaves ~300B for + // the aps container. Oversized events fall through to NSE's existing + // fetch-from-relay path (same broken behavior as build 21, no regression). + const eventJSON = JSON.stringify(event); + const eventBytes = Buffer.byteLength(eventJSON); + if (eventBytes <= 3500) { + pushPayload.event = event; + } else { + console.log( + `[Push] Event ${event.id.slice(0, 8)} too large (${eventBytes}B), omitting embed — NSE will fall back to relay fetch` + ); + } + for (const entry of matchingTokens) { try { const status = await sendPush(entry.token, pushPayload);