Repository navigation
feat: register with the wake gateway and receive wakes - #1277
coreyphillips wants to merge 16 commits into
Conversation
The key decrypted Blocktank's notifications, which ended with the sunset, and nothing reads it since. Wakes get their own key pair, so a migration deletes the stored key once, retrying on the next launch while the keychain cannot be read.
Phase 1 of the wake gateway adoption: device plumbing only, with no handler doing work yet. - Off unless configured: Env.wakeGatewayUrl is nil on every network, and a debug build takes WAKE_GATEWAY_URL from its environment or launch arguments. - Opt-in: WakeRegistrationPolicy registers only with notifications authorized and enabled, and never in E2E builds. Turning notifications off in MainNavView unregisters. - The install id, encryption key pair and device secret are made once and kept in the shared keychain, with the last registration and its timestamp, so the notification extension can decrypt and acknowledge. - Registration runs when the node is ready, the token, the pk: identity or the topics change, and is skipped when nothing changed. It signs ln: with LightningService.sign and pk: when the profile's key can be read, reads the audience from the gateway, uses max(now, last + 1) as the timestamp and signs again once with the gateway's time on 401 stale_timestamp. Topics are paykit.* and swap.*. - Background pushes reach the app delegate (remote-notification background mode), alert pushes the extension, which never starts the node. Both decrypt with core, dedupe by id in the group.bitkit app group, drop wakes past their deadline and acknowledge through a topic-to-handler registry: wake.test is handled in the extension, any other topic is acknowledged noHandler for now. - Presence is set while the scene is active, renewed every 60 s with a 90 s ttl, and cleared on background. A wallet wipe unregisters before the keychain is wiped. - requestPermissionIfUndecided now requests the device token, and the token is no longer logged.
Debug builds only: the gateway in use, what the gateway stored for the device (wakeGetDevice), Register Now, and Send Test Wake (wakeSendTest), which replaces the old Dev Settings push test.
Skipped unless WAKE_LIVE_GATEWAY_URL and WAKE_LIVE_FAKE_PUSH_URL point at the wake repository's docker stack. It registers with pk:, registers a new token within the same second, recovers from a stale timestamp, decrypts and acknowledges the recorded wake.test alert and a peer wake, dedupes the repeat, and checks that the secret is refused after unregistering.
The server sent a cleartext v0-style alert that nothing decrypts any more. It now takes the newest push the wake gateway's fake APNs recorded and sends the same v1 body to APNs with the same push type, priority, expiry and collapse id, so a device test gets the exact shape the gateway sends. DRY_RUN=true prints it without sending.
PushNotificationManager no longer handles incoming payments. It holds the permission and the device token, and WakeManager handles wakes.
a2ea3b3 to
3f41ef4
Compare
[Medium impact] The changes affect wake delivery for devices using a configured gateway. Add automatic recovery for failed registrations before merging, so a temporary gateway outage does not leave devices without wakes. Findings
SummaryAdds device registration and wake receipt through the wake gateway.
Acknowledged by
Diagram%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Registration inputs change] --> B{Gateway and permission allowed?}
B -->|Yes| C[Sign and send registration]
C --> D[Save accepted registration]
D --> E[Renew presence while active]
F[APNs wake] --> G[App or notification extension]
G --> H[Decrypt and claim wake id]
H --> I[Choose topic outcome]
I --> J[Acknowledge to saved gateway]
K[Notifications off or wallet wipe] --> L[Unregister]
Reviews (1) · Last reviewed commit: "style: wrap long lines in the live wake ..." · Reviewed by Greptile |
talosmachina
left a comment
There was a problem hiding this comment.
1 finding (P2). Adds the device side of the wake gateway: install credentials in the shared keychain, a signed registration, wakes received in the app delegate and the notification extension, acks, presence and unregister, all off unless a debug build names a gateway. Reviewed 3f41ef4, full tier on the production code, static: iOS does not build here, so this is reasoned from the code and CI.
What I checked, and 6 candidates I ruled out
Read in full: WakeManager.swift, WakeReceiver.swift, WakeSeenStore.swift, WakeStorage.swift, NotificationService.swift, Deadline.swift, and the diffs to AppScene, BitkitApp, MainNavView, Keychain, AppDataMigrations, AppReset, Env, PushNotificationManager, Info.plist, project.pbxproj
Skimmed: the test files and fixtures, WakeDevSettingsSection, test-push-server/
Ruled out
- Release builds registering:
Env.wakeGatewayUrlreadsWAKE_GATEWAY_URLonly underisDebugand is nil on every network otherwise, soregisterIfAllowedreturns.notConfigured. - Register/unregister racing on a toggle: both go through
serialized, andregisterreadslatestInputswhen it runs, so a disable queued behind an in-flight registration unregisters what it saved. - Extension cannot read credentials: the new entries use the existing access group with
AfterFirstUnlockThisDeviceOnly, and the three Wake files are added to theBitkitNotificationmembership exceptions. - Background push completion never called:
withDeadlinereturns nil at 25 s andfetchResult(nil)is.failed, so the handler always completes. - Legacy key migration before first unlock: a failed delete leaves the version at 4 and retries next launch.
- Wipe losing the device secret before unregister:
AppResetunregisters in.removeRemoteEndpoints, before the keychain wipe.
CI: build-local failed at "Verify current PR merge checkout" (the merge ref did not match the current base and head), before any build step, and e2e-status failed with it; Run Tests still running.
Merge confidence: 3/5, CI is red for a checkout check I could not tie to the change, and nothing here was built.
| authorizationStatus: inputs.authorizationStatus, | ||
| notificationsEnabled: inputs.notificationsEnabled | ||
| ) | ||
| else { return .notAllowed } |
There was a problem hiding this comment.
P2: an unregister that fails is never retried. Turning notifications off fires Task { await WakeManager.shared.unregister() } once (MainNavView.swift:384 and :398). If wakeUnregister fails for any reason other than a 401 (offline, timeout, gateway 5xx), unregisterRegisteredDevice only logs and keeps record.device. After that, every registerIfAllowed returns .notAllowed right here without looking at the stored device, so nothing tries again until the user turns notifications back on. The gateway keeps linking the ln: node id, the pk: key and the push token, which the policy comment above says should only happen while notifications are on.
Fix: in this else, when WakeStorage.loadRegistration().device is non-nil, call unregisterRegisteredDevice() before returning .notAllowed. Since the .task(id:) runs again on every input change and every launch, that retries on its own. A test can stub a failing unregister, then call registerIfAllowed with notifications off and check that the unregister is attempted again.
There was a problem hiding this comment.
The gap is real: unregisterRegisteredDevice keeps record.device after a non-401 failure, and register returns .notAllowed at WakeManager.swift:275 without retrying. The suggested fix would unregister on every cold launch, though. PushNotificationManager.authorizationStatus starts as .notDetermined and is only filled in asynchronously, so the first registerIfAllowed always fails the policy check. Getting this right means deciding when an opt-out is actually known, and the Android twin should do the same, so it goes in its own issue. Filed as #1311.
|
Filed separately
|
|
Thanks for filing #1310. I’ll treat registration recovery as tracked separately, so this doesn’t need to remain an outstanding finding on this PR. Tip: You can customize Greptile's behavior for this repo with |
|
Filed separately
|
jvsena42
left a comment
There was a problem hiding this comment.
Two MEDIUM and one LOW inline. Requesting changes for the MEDIUMs:
- The overflow trap is dev-gated today and user-facing once a gateway URL ships.
remote-notificationin Info.plist already ships in every build.
Checked and clean:
- No funds path from a wake: the app registry is empty and the extension's
wake.testis a no-op. - Payload values stay inert: none reaches a URL, amount or wallet selector.
- No seed-derived secret leaves the device: registration sends the device token, install id, wake encryption public key, SHA-256 of the device secret,
ln:andpk:. The push token is no longer logged. - Bounded signing: the signed preimage is the fixed
wake-register-v1form, and core rejects injection. The timestamp is saved before sending. - Wipe ordering:
isNodeReadyincludes!wipeCoordinator.isGateUp, and unregister runs before the keychain wipe.
Android synonymdev/bitkit-android#1847 doesn't have the overflow (Long wraps and a negative deadline lands in Stale) or the background node start (WakeWorker gets only WakeInbox). The unregister gap there is the opt-out thread already open.
Reviewer model: Claude Fable 5.2 (claude-fable-5-2), Claude Opus 5.5 (claude-opus-5-5)
Reasoning effort: not exposed
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — first notification opt-in and both recovery reproductions fail.
Findings:
- MEDIUM — Refresh authorization after granting notification permission (
Bitkit/AppScene.swift:1459). The normal Settings permission flow obtains an APNs token without updating the cached authorization status used here. On a fresh wallet, notifications were on and the node was running, but Register Now returnednotAllowed. Backgrounding and reopening the same process immediately registered. Refresh the status whenrequestPermission()completes. - MEDIUM — Invalidate cached content before an uncertain registration update (
Bitkit/Managers/WakeManager.swift:210). Registerln:only, addpk:, let the gateway accept that update but lose its response, then sign out. The oldln:-only cache makes the removal returnunchanged, leaving the signed-out Pubky identity registered remotely. Invalidate that cache before sending changed content while retaining the timestamp. - MEDIUM — Retry an undelivered acknowledgement on repeat delivery (
Bitkit/Services/Wake/WakeReceiver.swift:130). After handling a wake, a failed ack leaves its id claimed. Every repeat returnsduplicatebefore another ack attempt, so connectivity recovery cannot acknowledge it. Persist the outcome and retry the ack without repeating the handler.
QA for 3f41ef4:
Debug regtest build, iPhone 17 Pro simulator on iOS 27.0, local wake gateway. Recordings are 2× speed.
Register the wallet and inspect its identities ❌ First opt-in stayed unregistered; reopening unblocked registration, and creating a profile added the Pubky identity.
Case 1. The gateway accepted a real ln: proof, then both ln: and pk:, with paykit.* and swap.*. Gateway responses confirmed the full identities; the UI truncates that row.
| First opt-in and foreground workaround | Pubky profile registration |
|---|---|
v4-1277-register2x.2x.mp4 |
v4-1277-register-pubky2x.2x.mp4 |
Receive and acknowledge the test alert ⏭️ The injected alert displayed, but real APNs delivery and extension acknowledgement remain unverified.
Case 2. Send Test Wake returned 202. Simulator injection displayed the gateway's recorded encrypted alert but did not invoke the notification extension. Real APNs credentials were unavailable. The relay's DRY_RUN preserved the alert body, push type and priority.
v4-1277-delivery2x.2x.mp4
Recorded gateway alert delivered through simulator injection.
Disable notifications and revoke the device ✅ Opt-out unregistered the device and its old credential returned HTTP 401, checked through the gateway API.
Case 3. DELETE /v1/devices/self returned 204; a request using the retained pre-opt-out test credential returned 401.
v4-1277-optout2x.2x.mp4
Disabling notifications in Bitkit.
Wipe the wallet and revoke its old device ✅ Reset returned to onboarding and the old credential returned HTTP 401, checked through the gateway API.
Case 4. Notifications were re-enabled and registration confirmed before wiping. The wipe unregistered with 204 before removing local wallet data.
v4-1277-wipe2x.2x.mp4
Wiping the registered test wallet.
Validate wake parsing and registration rules ✅ The original focused wake, keychain and migration XCTest suites passed.
The app and extension built successfully. SwiftFormat 0.64.6 passed on the changed Swift files. The recording shows the test host; assertions are verified by the XCTest result.
v4-1277-wake-unit2x.2x.mp4
Focused XCTest run.
Register, decrypt and acknowledge against the gateway ✅ Live gateway tests passed, including a direct app-delegate callback with exactly one completion and a recorded `no_handler` ack.
The original live test covered token changes, clock recovery, encrypted receipt, dedupe and unregister. An additional XCTest invoked the app-delegate receive entry point with a live encrypted wake and checked .noData completion once; the gateway recorded its ack. This directly tests the callback, without OS background delivery. Recordings show the test host; results come from XCTest and the gateway.
| Original live test | App-delegate callback |
|---|---|
v4-1277-wake-live2x.2x.mp4 |
v4-1277-delegate2x.2x.mp4 |
Renew foreground presence and clear it in the background ✅ The lease renewed after 60 seconds and cleared on background, verified through the gateway API.
The expiry advanced by 60 seconds. Backgrounding returned 204 from DELETE /v1/presence, then the device API reported no presence expiry.
v4-1277-presence2x.2x.mp4
Refreshing presence in the app, then backgrounding it.
Recover registration and acknowledgement after lost responses ❌ Both added XCTest reproductions failed against unchanged production sources.
The uncertain update returned unchanged after sign-out while the simulated gateway still held pk:. The ack reproduction called the handler once and attempted only one ack despite a second delivery after connectivity recovered. These use injected transport failures; the recording shows the test host, and XCTest assertions establish the failures.
v4-1277-wake-recovery2x.2x.mp4
Recovery reproduction run.
Deliver a background wake through the simulator OS ⏭️ Silent push injection was rejected by the simulator.
Keeping the recorded ciphertext and changing the outer APNs shape to content-available only returned UNErrorDomain 1401: Notification has no user visible content. OS background delivery remains unverified; the direct callback check above passed.
v4-1277-background2x.2x.mp4
Attempted background-delivery setup.
Twin: synonymdev/bitkit-android#1847 matches the registration, topic and presence design; its inbox already retries failed acknowledgements without repeating work. Both twins retain the accepted-content cache across an uncertain registration update.
Coverage: full 5557e80..3f41ef4, including all changed files and reached lifecycle code. Release configuration, Ring key reads, migration, expiry and cross-process dedupe were checked in code or tests, without dedicated device journeys. Real APNs delivery, the extension process and its device memory use remain unverified.
Reviewed by gpt-6.1-sol-high
# Conflicts: # Bitkit/AppScene.swift
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — network recovery still starts a deferred wallet in the background.
Findings:
- MEDIUM — Keep network restarts behind background launch deferral (
Bitkit/AppScene.swift:1126). The new guard defers the wallet, buthandleNetworkChange(true)still callsNodeRestarter.restartfor the stopped node. That reachesstartWallet()without checking the background state orisWalletStartDeferred. On a background-launched wallet, a disconnect/reconnect event began building LDK before any active scene transition. Apply the same deferral to automatic network restarts or the shared startup entry point.
QA for 3a1c95e:
Debug regtest build, iPhone 17 Pro simulator, iOS 27.0, local wake gateway. Recordings are 2× speed. The app and notification extension built, and SwiftFormat 0.64.6 passed on the changed Swift files.
Register immediately after the first notification grant ✅ First permission grant registered the real ln identity without reopening; profile creation added pk after the injected outage recovered, verified through the gateway API.
Case 1. First permission grant registered the real ln identity without reopening; profile creation added pk after the injected outage recovered, verified through the gateway API.
first-notification-grant.2x.mp4
profile-and-registration-retry.2x.mp4
Receive and acknowledge the test alert ⏭️ Send Test Wake returned 202 and the recorded alert displayed through simulator injection; real APNs delivery and extension-process acknowledgement were not verified.
Case 2. The relay attempt stopped because no APNs key file was available. Simulator injection displayed the recorded encrypted alert, with no acknowledgement request observed. The live gateway test below checks the extension handler logic in the test host; delivery through the notification extension process remains unverified.
test-alert-injection.2x.mp4
Disable notifications and revoke the device ✅ Turning notifications off returned 204 from unregister and the retained credential then received HTTP 401, checked through the gateway API.
Case 3. Turning notifications off returned 204 from unregister and the retained credential then received HTTP 401, checked through the gateway API.
notification-opt-out.2x.mp4
Wipe the wallet and revoke its old device ✅ Wiping the registered wallet returned to onboarding; unregister returned 204 and the pre-wipe credential then received HTTP 401, checked through the gateway API.
Case 4. Wiping the registered wallet returned to onboarding; unregister returned 204 and the pre-wipe credential then received HTTP 401, checked through the gateway API.
wallet-wipe.2x.mp4
Validate wake overflow, retries and registration rules ✅ Focused wake, keychain and migration tests passed, including saturation, implausible clocks, lost-response unregister, retry policy and acknowledgement persistence; assertions are verified by XCTest results.
The focused XCTest selection covers timestamp saturation, lost-response unregister, registration retries and acknowledgement retries, plus the existing parsing, policy, keychain and migration checks. The recording shows the test host; XCTest assertions establish the result.
wake-unit-tests.2x.mp4
Recover registration and acknowledgement after lost responses ✅ Both earlier failing reproductions now pass: the signed-out pk identity is removed after a lost update response, and redelivery retries the ack with one handler call; verified by XCTest assertions.
The same two review-only XCTest reproductions that failed in the earlier review now pass against unchanged production sources. The simulated gateway no longer retains the signed-out pk: identity, and the failed acknowledgement is attempted twice with one handler call. The recording shows the test host; XCTest assertions establish the result.
lost-response-regressions.2x.mp4
Register, decrypt and acknowledge against the gateway ✅ The live gateway integration passed signed registration, token changes, clock correction, encrypted alert and background receipt, deduplication, acknowledgements and unregister; verified by XCTest assertions against the gateway.
The repository live test registered with a real pk: signature, changed tokens, corrected a skewed timestamp, decrypted recorded alert and background pushes, acknowledged them, deduplicated redelivery and revoked the credential. The recording shows the test host; XCTest assertions against the gateway establish the result. This does not drive OS or APNs delivery.
live-gateway.2x.mp4
Keep the wallet stopped during a background launch ❌ With a test hook emitting network recovery, a real background launch deferred the wallet and then began LDK startup before any active transition; verified by app logs.
On 3a1c95e plus the test hook below, the app logged background state (2), deferred startup and a stopped node. Emitting connectivity recovery then reached LDK construction without an active transition. The recording shows the Home screen; the app logs establish the failure.
background-network-start.2x.mp4
Test hook and deciding log lines
Not pushed. The extra fetch background mode lets the simulator launch the app in the background. The debug hook emits a disconnect/reconnect input immediately after startup is deferred. The app's startup decisions are unchanged. The simulator returned UNErrorDomain 1401 for the silent payload but did background-launch the process; this reproduces startup behavior, not successful background wake delivery.
diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift
index 58033f0d..8e239c0c 100644
--- a/Bitkit/AppScene.swift
+++ b/Bitkit/AppScene.swift
@@ -1124,6 +1124,18 @@ struct AppScene: View {
guard !WalletLaunchPolicy.defersStart(applicationState: UIApplication.shared.applicationState) else {
Logger.info("Launched in the background, starting the wallet once the app is active", context: "AppScene")
isWalletStartDeferred = true
+ #if DEBUG
+ let marker = Env.appStorageUrl.appendingPathComponent("review-network-event") // test hook
+ if FileManager.default.fileExists(atPath: marker.path) { // test hook
+ try? FileManager.default.removeItem(at: marker) // test hook
+ Task { // test hook
+ Logger.info("REVIEW_NETWORK state=\(UIApplication.shared.applicationState.rawValue) deferred=\(isWalletStartDeferred) node=\(wallet.nodeLifecycleState.displayState)", context: "ReviewHook") // test hook
+ network.isConnected = false // test hook
+ try? await Task.sleep(for: .milliseconds(250)) // test hook
+ network.isConnected = true // test hook
+ } // test hook
+ } // test hook
+ #endif
return
}
diff --git a/Bitkit/Info.plist b/Bitkit/Info.plist
index 4d8a346c..66e3de50 100644
--- a/Bitkit/Info.plist
+++ b/Bitkit/Info.plist
@@ -86,6 +86,7 @@
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
+ <string>fetch</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>Scene phase changed: background
Launched in the background, starting the wallet once the app is active
REVIEW_NETWORK state=2 deferred=true node=Stopped
Network changed: disconnected
Network changed: connected
Network restored, retrying wallet start...
Building node...
Retry registration after a gateway outage ✅ With inputs unchanged, an injected HTTP 503 was retried after 30.9 seconds and the gateway accepted both identities; checked through app logs and gateway responses.
During profile creation, one injected HTTP 503 was followed by HTTP 200 after 30.9 seconds, with no input change or manual registration. The gateway then held both identities and the topic patterns. Checked through app logs and gateway responses.
profile-and-registration-retry.2x.mp4
Deliver a background wake through the simulator OS ⏭️ Silent notification injection was rejected; OS background wake delivery remains unverified.
The simulator returned UNErrorDomain 1401: Notification has no user visible content for the content-available-only shape. Adding fetch enabled process launch for the lifecycle reproduction above, but the notification was still rejected. This recording shows the background-launch attempt; the command result establishes the delivery limitation. Real APNs credentials were unavailable.
background-network-start.2x.mp4
The unchanged foreground presence behavior remains covered by the earlier review. Real APNs delivery and extension memory use on a physical device remain unverified.
Twin: bitkit-android#1847 feat: register with the wake gateway and receive wakes, checked at ea85604, matches the accepted-content invalidation, transient registration retry and acknowledgement retry behavior. Android's wake worker does not start the wallet node.
Reviewed the wake delta from 3f41ef4 to 3a1c95e and open review findings. Ring key reads, release configuration and migration remain covered by code inspection and focused tests; merged master features received no separate device journeys.
Reviewed by gpt-6.1-sol-high
There was a problem hiding this comment.
Verdict:
Findings: None. The background network-recovery finding is fixed at 4f75f80.
QA for 4f75f80:
Debug regtest build, iPhone 17 Pro simulator on iOS 27.0. The app and notification extension built from this head. Recordings are 2× speed; assertions and app logs establish the results described below.
Keep the wallet stopped during a background launch ✅ Network recovery left the background-launched node stopped; opening Bitkit started it once, verified through app logs.
The debug hook emitted disconnect/reconnect after launch deferred with application state background. The node remained Stopped with .launch pending through the recovery event. LDK construction and the single Node started log followed the active transition. The recording shows Home and opening Bitkit; logs establish the node state.
background-start.2x.mp4
Background launch followed by opening Bitkit.
INFOℹ️: Scene phase changed: background - AppScene [AppScene.swift: handleScenePhaseChange(_:) line: 1456]
INFOℹ️: Launched in the background, starting the wallet once the app is active - AppScene [AppScene.swift: handleWalletExistsChange(_:) line: 1136]
INFOℹ️: REVIEW_cold before state=2 deferred=Optional(Bitkit.DeferredWalletStart.launch) node=Stopped - ReviewHook [AppScene.swift: reviewNetworkRecovery(_:) line: 1101]
INFOℹ️: Network changed: disconnected - AppScene [AppScene.swift: handleNetworkChange(_:) line: 2420]
INFOℹ️: Network changed: connected - AppScene [AppScene.swift: handleNetworkChange(_:) line: 2420]
INFOℹ️: REVIEW_cold after state=2 deferred=Optional(Bitkit.DeferredWalletStart.launch) node=Stopped - ReviewHook [AppScene.swift: reviewNetworkRecovery(_:) line: 1106]
INFOℹ️: Scene phase changed: inactive - AppScene [AppScene.swift: handleScenePhaseChange(_:) line: 1456]
INFOℹ️: Scene phase changed: active - AppScene [AppScene.swift: handleScenePhaseChange(_:) line: 1456]
DEBUG: Building node... [LightningService.swift: setup(walletIndex:electrumServerUrl:rgsServerUrl:channelMigration:) line: 270]
INFOℹ️: Node started [LightningService.swift: start(onEvent:) line: 399]
Defer network recovery until the app is opened ✅ A stopped node's recovery deferred in the background and resumed once on foreground, verified through app logs.
A debug hook stopped the test node, cleared its stale cached running snapshot and emitted disconnect/reconnect after Home. NodeRestarter reached the shared startup entry, which left .start pending and the node stopped. Opening Bitkit then logged App opened, one LDK construction and one successful start. The recording shows backgrounding and reopening; logs establish the startup behavior.
warm-recovery.2x.mp4
Background network recovery followed by opening Bitkit.
INFOℹ️: Scene phase changed: inactive - AppScene [AppScene.swift: handleScenePhaseChange(_:) line: 1456]
INFOℹ️: Scene phase changed: background - AppScene [AppScene.swift: handleScenePhaseChange(_:) line: 1456]
INFOℹ️: Node stopped [LightningService.swift: stop(clearEventCallback:) line: 442]
INFOℹ️: REVIEW_warm before state=2 deferred=nil node=Stopped - ReviewHook [AppScene.swift: reviewNetworkRecovery(_:) line: 1101]
INFOℹ️: Network changed: disconnected - AppScene [AppScene.swift: handleNetworkChange(_:) line: 2422]
INFOℹ️: Network changed: connected - AppScene [AppScene.swift: handleNetworkChange(_:) line: 2422]
INFOℹ️: Network restored, retrying wallet start... - NodeRestarter [NodeRestarter.swift: restart(reason:) line: 54]
INFOℹ️: In the background, starting the wallet once the app is active - AppScene [AppScene.swift: startWallet(completingBackupRestore:playsErrorHaptic:) line: 1175]
INFOℹ️: REVIEW_warm after state=2 deferred=Optional(Bitkit.DeferredWalletStart.start) node=Stopped - ReviewHook [AppScene.swift: reviewNetworkRecovery(_:) line: 1106]
INFOℹ️: Scene phase changed: inactive - AppScene [AppScene.swift: handleScenePhaseChange(_:) line: 1456]
INFOℹ️: Scene phase changed: active - AppScene [AppScene.swift: handleScenePhaseChange(_:) line: 1456]
INFOℹ️: App opened, retrying wallet start... - NodeRestarter [NodeRestarter.swift: restart(reason:) line: 54]
DEBUG: Building node... [LightningService.swift: setup(walletIndex:electrumServerUrl:rgsServerUrl:channelMigration:) line: 270]
INFOℹ️: Node started [LightningService.swift: start(onEvent:) line: 399]
Preserve foreground retry and recovery rules ✅ The existing launch-policy and node-restarter tests passed; verified by XCTest assertions.
NodeStartRetryTests and WalletLaunchPolicyTests ran against unmodified production sources. They cover foreground retry, inactive transitions, offline and missing-wallet gates, recovery mode, and background launch policy. The recording shows the test host; XCTest results establish the assertions.
lifecycle.2x.mp4
Focused lifecycle tests.
Receive and acknowledge the test alert ⏭️ Case 2 remains unrun because the real APNs relay lacks a Bitkit APNs key.
The repository relay's prerequisite check stopped with APN key file not found. No real APNs delivery or notification-extension-process acknowledgement was established. Simulator alert injection in the previous review displayed the alert but did not establish that path; its direct live handler results remain applicable to the unchanged receiver.
Deliver a background wake through the simulator OS ⏭️ The simulator rejected silent notification injection; OS payload delivery remains unverified.
The repository's APNs background test vector returned UNErrorDomain 1401: Notification has no user visible content. The same result occurred with the pristine build and with the simulator-only fetch workaround. That workaround does background-launch the process, enabling the startup test above, but does not establish payload delivery or acknowledgement. The recording shows the injection attempt; the command result establishes the limitation.
silent.2x.mp4
Attempted silent-wake injection.
Debug hook used for startup checks
Not pushed. fetch enables simulator background process launch. The cold control emits connectivity changes after launch defers; the warm control stages a stopped test node before emitting connectivity changes. Startup decisions remain the reviewed code. The cold run used the same cold control; clearing the stopped node's cached running snapshot was added for the warm run.
diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift
index e928e5d03..df32447c9 100644
--- a/Bitkit/AppScene.swift
+++ b/Bitkit/AppScene.swift
@@ -1096,6 +1096,17 @@ struct AppScene: View {
// MARK: - Event Handlers
+ #if DEBUG
+ private func reviewNetworkRecovery(_ kind: String) async { // test hook
+ Logger.info("REVIEW_\(kind) before state=\(UIApplication.shared.applicationState.rawValue) deferred=\(String(describing: deferredWalletStart)) node=\(wallet.nodeLifecycleState.displayState)", context: "ReviewHook") // test hook
+ network.isConnected = false // test hook
+ try? await Task.sleep(for: .milliseconds(250)) // test hook
+ network.isConnected = true // test hook
+ try? await Task.sleep(for: .seconds(5)) // test hook
+ Logger.info("REVIEW_\(kind) after state=\(UIApplication.shared.applicationState.rawValue) deferred=\(String(describing: deferredWalletStart)) node=\(wallet.nodeLifecycleState.displayState)", context: "ReviewHook") // test hook
+ } // test hook
+ #endif
+
private func handleCurrencyStaleData(_: Bool) {
if currency.hasStaleData {
app.toast(type: .error, title: "Rates currently unavailable", description: "An error has occurred. Please try again later.")
@@ -1124,6 +1135,13 @@ struct AppScene: View {
guard !WalletLaunchPolicy.defersStart(applicationState: UIApplication.shared.applicationState) else {
Logger.info("Launched in the background, starting the wallet once the app is active", context: "AppScene")
deferredWalletStart = .launch
+ #if DEBUG
+ let marker = Env.appStorageUrl.appendingPathComponent("review-network-event") // test hook
+ if FileManager.default.fileExists(atPath: marker.path) { // test hook
+ try? FileManager.default.removeItem(at: marker) // test hook
+ Task { await reviewNetworkRecovery("cold") } // test hook
+ } // test hook
+ #endif
return
}
@@ -1454,6 +1472,23 @@ struct AppScene: View {
WakeManager.shared.sceneEnteredBackground()
}
+ #if DEBUG
+ if newPhase == .background { // test hook
+ let marker = Env.appStorageUrl.appendingPathComponent("review-warm-event") // test hook
+ if FileManager.default.fileExists(atPath: marker.path) { // test hook
+ try? FileManager.default.removeItem(at: marker) // test hook
+ let background = UIApplication.shared.beginBackgroundTask(withName: "review-warm-recovery") // test hook
+ Task { // test hook
+ do { try await wallet.stopLightningNode() } catch { Logger.error(error, context: "ReviewHook") } // test hook
+ LightningService.shared.clearCache() // test hook: discard the running snapshot of the node just stopped
+ wallet.nodeLifecycleState = .stopped // test hook: stage the stopped-node recovery input
+ await reviewNetworkRecovery("warm") // test hook
+ UIApplication.shared.endBackgroundTask(background) // test hook
+ } // test hook
+ } // test hook
+ } // test hook
+ #endif
+
if newPhase == .background {
if settings.pinEnabled {
// If PIN is enabled, lock the app when the app goes to the background
diff --git a/Bitkit/Info.plist b/Bitkit/Info.plist
index 4d8a346cf..66e3de50b 100644
--- a/Bitkit/Info.plist
+++ b/Bitkit/Info.plist
@@ -86,6 +86,7 @@
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
+ <string>fetch</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>Unchanged cases 1, 3 and 4, registration retry, lost-response recovery, wake parsing and the live gateway integration retain the passing evidence from the previous review at 3a1c95e. Foreground presence retains its passing earlier report.
Twin: bitkit-android#1847 feat: register with the wake gateway and receive wakes, checked at ea85604, matches the intent to receive wakes without starting the wallet node: WakeWorker calls only WakeInbox.
Reviewed the contiguous delta from 3a1c95e to 4f75f80 and the open startup findings. Pending-backup restore and recovery-screen paths were inspected in code; no separate device restore journey was driven. Extension memory measurement remains outside this PR's stated scope.
Reviewed by gpt-6.1-sol-high
Part of #1238 (Phase 1)
Twin: synonymdev/bitkit-android#1847
Dependency: synonymdev/bitkit-core#195, released in
0.8.0-rc13Adds the device side of the wake gateway: install credentials, a signed registration, wakes received in the app delegate and the notification extension, acks, presence and unregister. Wakes stay off in any build without a gateway URL, so release builds behave as today until synonymdev/wake#2 deploys one.
Description
Env.wakeGatewayUrl: nil on every network. Only a debug build withWAKE_GATEWAY_URLin its environment or launch arguments has one, asPUBKY_SWAPS_ROUTESworks in feat: pay contacts via pubky swaps #1170.WakeRegistrationPolicy: registers only with notifications authorized andenableNotificationson, and never in E2E builds.MainNavViewunregisters at both places it turns notifications off.WakeStorage: keeps the install id, key pair, device secret, last registration and its timestamp in two entries of the shared keychain access group, so the extension can decrypt and ack. Nothing is backed up. A migration removes the orphanedpushNotificationPrivateKey, retrying until the keychain can be read.WakeManagerregisters from one.task(id:)inAppScene. The task reacts to the node running, the token, the authorization,enableNotificationsand the Pubky key.wakeServerInfo.max(now, last + 1), saved before sending. A 401stale_timestampsigns again once with the gateway's clock, unless that clock is outside 2020 to 2100. Sums of gateway values saturate.Retry-After, until the inputs change.Env.isDebug ? .sandbox : .productionand the token as lowercase hex.ln:withLightningService.sign.pk:follows the identity table in feat: adopt the wake gateway for background pushes #1238; while a stored identity cannot be read, the attempt is skipped rather than registeringln:alone.paykit.*andswap.*.wake.testneeds no subscription.PushNotificationManager:requestPermissionIfUndecidednow registers for remote notifications when granted,requestPermissionrefreshes the authorization status, and the token is no longer logged.Info.plistandAppDelegate: adds theremote-notificationbackground mode anddidReceiveRemoteNotification. The completion handler is always called, within 25 s. A background launch does not start the wallet, and no automatic start (network recovery included) runs in the background; it starts once the app is opened.WakeReceiver: one receive path for the app and the extension. It decrypts with core, takes each id once through a file ingroup.bitkitbehind a cross-process lock, drops wakes past their deadline, runs the topic's handler and acks. A failed ack is sent again when the wake is delivered again, without running the handler again. Acks go to the gateway saved with the registration, since background launches and the extension get no launch arguments.NotificationService: for alert pushes, decrypts and ackswake.testashandledand delivers the alert unchanged. It never starts the node and still delivers onserviceExtensionTimeWillExpire. In the app delegate, every topic acksnoHandleruntil the next phases add handlers.AppReset: unregisters in the.removeRemoteEndpointsstep, before the keychain wipe.test-push-server/: resends a push recorded by the local gateway's fake APNs to real APNs, with aDRY_RUNmode.AGENTS.mddescribes the push managers as they are now.Out of Scope
wake.testin the extension: they acknoHandlerfor now.Design
N/A: the only UI is the debug-only Dev Settings section.
Preview
N/A
QA Notes
Setup
docker compose -f docker/compose.yml --profile dev up -d --build --wait. The gateway listens on 127.0.0.1:9010, with fake push providers that record pushes instead of delivering them.WAKE_GATEWAY_URL=http://127.0.0.1:9010in the scheme's environment.test-push-server/can resend a recorded push to real APNs (see its README).Journeys
N/A: no user-visible change in release builds.
Manual Tests
ln:, pluspk:with a Pubky profile, and topicspaykit.*andswap.*test-push-server→ the alert shows, andwake statusshows the wake acked by the extension: needs a device and APNs credentialsAutomated Checks
WakeReceiverTests.swift: every APNs shape in core'spushes.jsondecrypts, fallback alerts need no key, a wake is handled once across the app and the extension, wakes past their deadline are dropped, ack outcomes and background fetch results, the extension handles onlywake.test, the shared id store, a failed ack sent again on redelivery, a deadline nearUInt64.maxWakeRegistrationTests.swift: the opt-in gate, no work without a gateway or in E2E builds, identity selection, credentials made once, timestamps across launches and token changes, the 401 retry once, a timestamp kept when the answer is lost, skipping unchanged registrations, an implausible gateway clock, a lost update not taken as unchanged, unregistering after a lost answer, retry delays, the background launch checkWakeGatewayLiveTests.swift: registers, receives, acks and unregisters against a local gateway; skipped unlessWAKE_GATEWAY_URLis setKeychainTests.swift: the orphaned key is deletedAppDataMigrationsTests.swift: the keychain cleanup is retried until it succeedsEvidence
On
3a1c95ef, and the full unit suite again on4f75f803(master merged in, bitkit-core0.8.0-rc15), Xcode 27.1, iPhone 17 simulator:xcodebuild testwith CI's flags: BitkitTests ran 4,013 tests, 0 failures, 5 skipped (the live wake test, a USDT fixture test and three Spark regtest tests). BitkitUITests ran 12, 0 failures, 4 skipped.WakeGatewayLiveTestsagainst the gateway's docker stack: passed.Earlier runs:
Run on this branch with Xcode 27.1 and the iPhone 18 Pro simulator. The commits after the live test change only
test-push-server/andAGENTS.md. After the rebase onto master (bitkit-core0.8.0-rc15), the build and the 68 wake, keychain and migration tests passed again on3f41ef42.xcodebuild build-for-testingfor the app and the extension: passed, with no warnings.xcodebuild testwith CI's flags: BitkitTests ran 3,231 tests, 0 failures, 2 skipped (a USDT fixture test and the live wake test). BitkitUITests ran 44, 0 failures, 4 skipped.WakeGatewayLiveTestsagainst the gateway's docker stack: passed. It registered withpk:only, since a test has no node to signln:. The gateway's metrics showed:stale_timestampfrom a skewed clock, recovered by the retry;wake.testackedhandled, and apaykit.syncpeer wake ackednoHandlerwith its repeat deduped;Gaps
AppScenetriggers, a realln:signature, the presence loop and the extension process were compiled and unit tested only.Open questions
MainNavViewsetsenableNotificationsback on at each launch while iOS allows notifications, so the gate follows iOS permission. Provisional authorization does not count.stale_registrationis not retried, since retrying would take the token back from a device that signed later.