Skip to content

feat: register with the wake gateway and receive wakes - #1277

Open
coreyphillips wants to merge 16 commits into
masterfrom
feat/wake-gateway-phase-1
Open

coreyphillips wants to merge 16 commits into
masterfrom
feat/wake-gateway-phase-1

Conversation

@coreyphillips

@coreyphillips coreyphillips commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Part of #1238 (Phase 1)
Twin: synonymdev/bitkit-android#1847
Dependency: synonymdev/bitkit-core#195, released in 0.8.0-rc13

Adds 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 with WAKE_GATEWAY_URL in its environment or launch arguments has one, as PUBKY_SWAPS_ROUTES works in feat: pay contacts via pubky swaps #1170.
  • WakeRegistrationPolicy: registers only with notifications authorized and enableNotifications on, and never in E2E builds. MainNavView unregisters 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 orphaned pushNotificationPrivateKey, retrying until the keychain can be read.
  • WakeManager registers from one .task(id:) in AppScene. The task reacts to the node running, the token, the authorization, enableNotifications and the Pubky key.
    • It skips when the stored registration matches, and the audience comes from wakeServerInfo.
    • The timestamp is max(now, last + 1), saved before sending. A 401 stale_timestamp signs again once with the gateway's clock, unless that clock is outside 2020 to 2100. Sums of gateway values saturate.
    • The gateway URL is saved before sending, so opt-out and wipe unregister there even if the answer was lost. A cached registration is dropped before changed content is sent.
    • A transient failure (network, 5xx, 429) is tried again after 30 s, doubling to 15 min, or after a longer Retry-After, until the inputs change.
    • Uses Env.isDebug ? .sandbox : .production and the token as lowercase hex.
    • Proves ln: with LightningService.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 registering ln: alone.
    • Subscribes to paykit.* and swap.*. wake.test needs no subscription.
  • PushNotificationManager: requestPermissionIfUndecided now registers for remote notifications when granted, requestPermission refreshes the authorization status, and the token is no longer logged.
  • Info.plist and AppDelegate: adds the remote-notification background mode and didReceiveRemoteNotification. 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 in group.bitkit behind 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 acks wake.test as handled and delivers the alert unchanged. It never starts the node and still delivers on serviceExtensionTimeWillExpire. In the app delegate, every topic acks noHandler until the next phases add handlers.
  • Presence: set when the scene becomes active, renewed every 60 s with a 90 s ttl, cleared on background.
  • AppReset: unregisters in the .removeRemoteEndpoints step, before the keychain wipe.
  • Dev Settings, debug only: a Wake section with the gateway URL, device status, Register Now and Send Test Wake.
  • test-push-server/: resends a push recorded by the local gateway's fake APNs to real APNs, with a DRY_RUN mode. AGENTS.md describes the push managers as they are now.

Out of Scope

Design

N/A: the only UI is the debug-only Dev Settings section.

Preview

N/A

QA Notes

Setup

  1. In synonymdev/wake, run 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.
  2. Run a debug build with WAKE_GATEWAY_URL=http://127.0.0.1:9010 in the scheme's environment.
  3. Turn notifications on, then open Dev Settings, Wake.
  4. For delivery on a device, 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

  • 1. With a gateway set and notifications on, tap Device → it shows ln:, plus pk: with a Pubky profile, and topics paykit.* and swap.*
  • 2. Tap Send Test Wake, then resend the recorded push with test-push-server → the alert shows, and wake status shows the wake acked by the extension: needs a device and APNs credentials
  • 3. Turn notifications off in Bitkit, then tap Device → the gateway answers 401, since the device was unregistered
  • 4. Wipe the wallet → the old device secret gets a 401

Automated Checks

  • added WakeReceiverTests.swift: every APNs shape in core's pushes.json decrypts, 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 only wake.test, the shared id store, a failed ack sent again on redelivery, a deadline near UInt64.max
  • added WakeRegistrationTests.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 check
  • added WakeGatewayLiveTests.swift: registers, receives, acks and unregisters against a local gateway; skipped unless WAKE_GATEWAY_URL is set
  • updated KeychainTests.swift: the orphaned key is deleted
  • updated AppDataMigrationsTests.swift: the keychain cleanup is retried until it succeeds

Evidence

On 3a1c95ef, and the full unit suite again on 4f75f803 (master merged in, bitkit-core 0.8.0-rc15), Xcode 27.1, iPhone 17 simulator:

  • xcodebuild test with 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.
  • WakeGatewayLiveTests against the gateway's docker stack: passed.
  • SwiftFormat lint: the changed Swift files need no formatting.

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/ and AGENTS.md. After the rebase onto master (bitkit-core 0.8.0-rc15), the build and the 68 wake, keychain and migration tests passed again on 3f41ef42.

  • xcodebuild build-for-testing for the app and the extension: passed, with no warnings.
  • xcodebuild test with 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.
  • WakeGatewayLiveTests against the gateway's docker stack: passed. It registered with pk: only, since a test has no node to sign ln:. The gateway's metrics showed:
    • one 201 and two 200 registrations, with no 409 after a token change in the same second;
    • a 401 stale_timestamp from a skewed clock, recovered by the retry;
    • wake.test acked handled, and a paykit.sync peer wake acked noHandler with its repeat deduped;
    • unregister answered 204, and the old secret then got a 401.

Gaps

  • The app was not run end to end: the AppScene triggers, a real ln: signature, the presence loop and the extension process were compiled and unit tested only.
  • No device run and no real APNs delivery. The background launch check is unit tested only: the simulator does not launch an app for a background push.

Open questions

  • Opt-in in practice: MainNavView sets enableNotifications back on at each launch while iOS allows notifications, so the gate follows iOS permission. Provisional authorization does not count.
  • A wake is marked seen before its work runs, so a crash mid-work loses it. The Android twin marks it after. Which should both use once Phase 2 adds real work?
  • A failed unregister keeps the local record and is not retried, so presence can keep renewing until the next opt-out or wipe (Wake unregister is not retried after it fails, so the gateway keeps the device after opt-out #1311).
  • A 409 stale_registration is not retried, since retrying would take the token back from a device that signed later.

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.
@coreyphillips
coreyphillips force-pushed the feat/wake-gateway-phase-1 branch from a2ea3b3 to 3f41ef4 Compare November 12, 2026 15:45
@coreyphillips
coreyphillips marked this pull request as ready for review November 12, 2026 15:47
@greptile-apps

greptile-apps Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5

[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

  1. P1 Registration never retries ▶

Summary

Adds device registration and wake receipt through the wake gateway.

  • Bitkit registers eligible devices with a configured wake gateway.
  • The app and notification extension receive and acknowledge gateway wakes.
  • Debug settings show wake status and offer gateway test actions.
  • The push test tool relays a recorded gateway push to APNs.

Acknowledged by coreyphillips and excluded from findings:

  • Wakes are marked seen before work runs; a crash can lose the work. The PR leaves this question open for Phase 2.
  • Failed unregister requests keep the local record and are not retried; presence can continue.
  • Notification settings follow iOS permission again at launch.
  • 409 stale_registration is deliberately not retried, to avoid taking a token back from a later registration.
  • Swap and Paykit handlers, other alert handlers, and extension memory measurements are deferred.

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]
Loading

Reviews (1) · Last reviewed commit: "style: wrap long lines in the live wake ..." · Reviewed by Greptile

Comment thread Bitkit/AppScene.swift Resolved

@talosmachina talosmachina left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.wakeGatewayUrl reads WAKE_GATEWAY_URL only under isDebug and is nil on every network otherwise, so registerIfAllowed returns .notConfigured.
  • Register/unregister racing on a toggle: both go through serialized, and register reads latestInputs when 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 the BitkitNotification membership exceptions.
  • Background push completion never called: withDeadline returns nil at 25 s and fetchResult(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: AppReset unregisters 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 }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@coreyphillips

Copy link
Copy Markdown
Contributor Author

Filed separately

@greptile-apps

greptile-apps Bot commented Nov 12, 2026

Copy link
Copy Markdown

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 .greptile/rules.md and .greptile/config.json.

@coreyphillips

Copy link
Copy Markdown
Contributor Author

Filed separately

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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-notification in 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.test is 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: and pk:. The push token is no longer logged.
  • Bounded signing: the signed preimage is the fixed wake-register-v1 form, and core rejects injection. The timestamp is saved before sending.
  • Wipe ordering: isNodeReady includes !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.

⚠️ Device gate: not run — manual tests 1-4 need the local wake gateway, which is not available on my setup; the background-launch case needs a physical device. perf: a 25 s background task per push and a 60 s presence loop while foregrounded; the node start on background launch is the item to measure.

Reviewer model: Claude Fable 5.2 (claude-fable-5-2), Claude Opus 5.5 (claude-opus-5-5)
Reasoning effort: not exposed

Comment thread Bitkit/Services/Wake/WakeStorage.swift Resolved
Comment thread Bitkit/Info.plist Resolved
Comment thread Bitkit/Managers/WakeManager.swift Resolved

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 returned notAllowed. Backgrounding and reopening the same process immediately registered. Refresh the status when requestPermission() completes.
  • MEDIUM — Invalidate cached content before an uncertain registration update (Bitkit/Managers/WakeManager.swift:210). Register ln: only, add pk:, let the gateway accept that update but lose its response, then sign out. The old ln:-only cache makes the removal return unchanged, 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 returns duplicate before 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

Comment thread Bitkit/AppScene.swift Resolved
Comment thread Bitkit/Managers/WakeManager.swift Resolved
Comment thread Bitkit/Services/Wake/WakeReceiver.swift Resolved

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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, but handleNetworkChange(true) still calls NodeRestarter.restart for the stopped node. That reaches startWallet() without checking the background state or isWalletStartDeferred. 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

Comment thread Bitkit/AppScene.swift Resolved

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ⚠️ Not verified — the startup fix passes; real APNs and OS silent-wake delivery remain unverified.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants