From 4231bf8f5e56206e3041b3fc7d9e822576568af9 Mon Sep 17 00:00:00 2001 From: DocNR Date: Mon, 20 Apr 2026 22:13:23 -0400 Subject: [PATCH 1/5] =?UTF-8?q?proxy:=20embed=20caught=20event=20in=20APNs?= =?UTF-8?q?=20push=20payload=20(=E2=89=A43500B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avoids the NSE ephemeral-fetch race when the relay drops kind:24133 between proxy dispatch and NSE wake. Oversized events fall through to the existing fetch path (no regression). Part of build 22 response-delivery hotfix. See ~/hq/clave/specs/2026-04-20-response-delivery-fixes-design.md --- relay-proxy/proxy.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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); From 30c2aeeee1b8cd7487ee4a61cc0a0ab373385794 Mon Sep 17 00:00:00 2001 From: DocNR Date: Mon, 20 Apr 2026 22:39:11 -0400 Subject: [PATCH 2/5] ios: prefer embedded event from push payload over relay fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NSE and AppDelegate foreground-push handler now check userInfo["event"] first; fall through to LightRelay.fetchEvents only if absent. Closes the ephemeral-fetch race for ≤3500B events. Oversized events fall through as today. Part of build 22 response-delivery hotfix. --- Clave/ClaveApp.swift | 52 ++++++++++++++++++------------ ClaveNSE/NotificationService.swift | 52 ++++++++++++++++++------------ 2 files changed, 63 insertions(+), 41 deletions(-) diff --git a/Clave/ClaveApp.swift b/Clave/ClaveApp.swift index b774d71..667e916 100644 --- a/Clave/ClaveApp.swift +++ b/Clave/ClaveApp.swift @@ -103,32 +103,42 @@ 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 { + 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..fbe5cf7 100644 --- a/ClaveNSE/NotificationService.swift +++ b/ClaveNSE/NotificationService.swift @@ -110,32 +110,44 @@ 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 { + 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) From 76724f6d797d06672f8fcbfb9bc93ac21284c18a Mon Sep 17 00:00:00 2001 From: DocNR Date: Mon, 20 Apr 2026 22:47:42 -0400 Subject: [PATCH 3/5] ios: distinguish cast-miss from key-missing in embed fallback log Spec Risk #1 mitigation: when userInfo["event"] is present but not a dict, log a warning so proxy payload-shape regressions are visible in production triage. Unchanged: behavior on happy path + missing-key path. --- Clave/ClaveApp.swift | 6 +++++- ClaveNSE/NotificationService.swift | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Clave/ClaveApp.swift b/Clave/ClaveApp.swift index 667e916..3a98b0e 100644 --- a/Clave/ClaveApp.swift +++ b/Clave/ClaveApp.swift @@ -111,7 +111,11 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele logger.notice("[App] Using embedded event from push payload") events = [embedded] } else { - logger.notice("[App] No embedded event; fetching from \(relayUrlString, privacy: .public)") + 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() diff --git a/ClaveNSE/NotificationService.swift b/ClaveNSE/NotificationService.swift index fbe5cf7..56c51e0 100644 --- a/ClaveNSE/NotificationService.swift +++ b/ClaveNSE/NotificationService.swift @@ -120,7 +120,11 @@ class NotificationService: UNNotificationServiceExtension { logger.notice("[ClaveNSE] Using embedded event from push payload") events = [embedded] } else { - logger.notice("[ClaveNSE] No embedded event; fetching from \(relayUrlString, privacy: .public)") + 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() From 96bca749c5155d7c39efdf6b37577460ace48fea Mon Sep 17 00:00:00 2001 From: DocNR Date: Mon, 20 Apr 2026 23:17:38 -0400 Subject: [PATCH 4/5] ios: thread relay URL through PendingRequest approve-later flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PendingRequest gains relayUrl: String? captured at queue-time from the incoming responseRelayUrl. approvePendingRequest threads it back into LightSigner.handleRequest, so signed responses for protected kinds publish to the relay the client is actually subscribed on — not the powr.build fallback. Bug was latent since PR #7 (switch_relays → null) — nostr-tools clients stopped migrating to powr.build, so the fallback stopped matching. Bunker flow unaffected because Clave's bunker URIs are already pinned to powr.build. Codable's default synthesis handles missing keys on Optional fields, so pre-build-22 pending rows decode as nil → fall back to powr.build (unchanged broken behavior for pre-upgrade stragglers). Part of build 22 response-delivery hotfix. --- Clave/AppState.swift | 7 ++++++- Shared/LightSigner.swift | 3 ++- Shared/SharedModels.swift | 10 ++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Clave/AppState.swift b/Clave/AppState.swift index 2c375af..f7320b6 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.relayUrl + ) SharedStorage.removePendingRequest(id: request.id) refreshPendingRequests() return result.status == "signed" diff --git a/Shared/LightSigner.swift b/Shared/LightSigner.swift index 28cb374..60e4b0f 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, + relayUrl: responseRelayUrl ) SharedStorage.queuePendingRequest(pending) } diff --git a/Shared/SharedModels.swift b/Shared/SharedModels.swift index 87e1ba5..9ca120e 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 forward 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 relayUrl: String? } struct ConnectedClient: Codable, Identifiable { From 10d93568391bfb00230702666b2ef5f51ed6a942 Mon Sep 17 00:00:00 2001 From: DocNR Date: Mon, 20 Apr 2026 23:24:11 -0400 Subject: [PATCH 5/5] ios: rename PendingRequest.relayUrl to responseRelayUrl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the parameter name on LightSigner.handleRequest so the call site reads `responseRelayUrl: request.responseRelayUrl` instead of `responseRelayUrl: request.relayUrl` — self-documenting round trip. Also corrects "forward" → "backward" compatibility in the field's doc comment (new code reading pre-upgrade rows is backward compat, not forward). --- Clave/AppState.swift | 2 +- Shared/LightSigner.swift | 2 +- Shared/SharedModels.swift | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Clave/AppState.swift b/Clave/AppState.swift index f7320b6..b27ce10 100644 --- a/Clave/AppState.swift +++ b/Clave/AppState.swift @@ -289,7 +289,7 @@ final class AppState { privateKey: privateKey, requestEvent: requestEvent, skipProtection: true, - responseRelayUrl: request.relayUrl + responseRelayUrl: request.responseRelayUrl ) SharedStorage.removePendingRequest(id: request.id) refreshPendingRequests() diff --git a/Shared/LightSigner.swift b/Shared/LightSigner.swift index 60e4b0f..31eaf79 100644 --- a/Shared/LightSigner.swift +++ b/Shared/LightSigner.swift @@ -198,7 +198,7 @@ enum LightSigner { eventKind: eventKind ?? 0, clientPubkey: senderPubkey, timestamp: Date().timeIntervalSince1970, - relayUrl: responseRelayUrl + responseRelayUrl: responseRelayUrl ) SharedStorage.queuePendingRequest(pending) } diff --git a/Shared/SharedModels.swift b/Shared/SharedModels.swift index 9ca120e..b3dacc5 100644 --- a/Shared/SharedModels.swift +++ b/Shared/SharedModels.swift @@ -23,11 +23,11 @@ struct PendingRequest: Codable, Identifiable { /// to the same relay the client is actually subscribed on — not the /// powr.build fallback. /// - /// Optional for forward compatibility — Codable decodes missing keys on + /// 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 relayUrl: String? + let responseRelayUrl: String? } struct ConnectedClient: Codable, Identifiable {