Skip to content

feat: adopt the wake gateway for background pushes #1804

Open

Description

@coreyphillips

Twin: synonymdev/bitkit-ios#1238
Refs: synonymdev/bitkit-core#160, synonymdev/bitkit-core#195, synonymdev/bitkit-core#200, #1696, #1350, #1732

Problem or use case

Bitkit has had no push path since the Blocktank sunset (#1696), which removed registration, decryption and WakeNodeWorker. On master:

  • FcmService.onMessageReceived acts only on message.notification, so a data message is logged and dropped.
  • onNewToken only logs, and nothing reads an FCM token.
  • The node stops on ON_STOP unless the user opted in to staying active, and nothing can start it without an Activity.

So Paykit requests are found only by foreground polling, a pubky swap the wallet started cannot be claimed until the user opens the app again (#1835), and a Lightning payment that arrives while the app is closed waits until the user opens it.

synonymdev/bitkit-core#160 added the client for the wake gateway (synonymdev/wake), the service that replaces bitkit-notification-server. Nothing calls it yet. This is new code, not a URL swap.

Blocked by

Phase 1 can start against the gateway's local stack, but it cannot ship until these land:

Proposed solution

Three phases. Each one ships on its own. The Lightning node wake is not planned.

Phase 1: device plumbing

Core and config

Credentials

  • Generate once and keep in Keychain: the install id, the encryption key pair and the device secret. Also keep the last registration timestamp.
  • Reuse them across launches. They belong to this install, so leave them out of VSS backups.
  • Do not reuse the old key. Remove the orphaned PUSH_NOTIFICATION_PRIVATE_KEY.

Registration

  • Register when the node is running, from onNewToken, and when the pk: identity or the topic list changes. Skip it when nothing changed since the last success.
  • Pass timestamp explicitly as max(now, last + 1), using the stored last timestamp. The gateway answers 409 stale_registration to a timestamp older than the install's last registration, or equal to it with different content. Since fix(wake): close the client gaps from the gateway review bitkit-core#195 core's default does this within one process, but it starts over on each launch, so a relaunch in the same second or a clock that steps back would still get a 409.
  • On 401 stale_timestamp, prepare again with the error's serverTime as timestamp and sign again once.
  • Use the production environment. Core rejects sandbox for FCM.
  • The ln: proof uses LightningService.sign, which has no callers today and no LightningRepo wrapper.
  • Subscribe with patterns (paykit.*, swap.*), as the wire protocol advises, so a topic added on the server arrives without an app release. Leave out blocktank.*, which nothing will send.

The pk: proof depends on the wallet's identity state. A wallet needs no Pubky profile, so ln: alone is valid.

Identity state Identities Notes
No profile (the default) ln: Do not use deriveKeys() here: it would register a key the user never published
Local identity ln:, pk: The key is in PUBKY_SECRET_KEY
Ring-adopted ln:, pk: Read the Ring credential just in time
Session without a local key ln: There is no secret to sign with
  • PubkyRepo.activeSecretKeyHex is private. Add a narrow method that signs a registration through wakeSignPubkyProof, rather than exposing the hex.
  • PubkyRepo.publicKey has a pubky prefix. Core wants pk: plus the 52 bare characters.
  • If reading the key fails for a moment, skip the attempt. Do not register ln: alone, which would drop pk: and any grants tied to it.

Receiving

  • Serialize message.data to JSON and pass it to wakeDecryptPush. Every v1 wake is a data message.
  • Then dedupe by id, drop anything past deadline, do the work, and ack. Persist seen ids, because the process can die between pushes and delivery is at least once.
  • Ack handled only when the work is done, since it cancels the fallback alert. Ack no_handler only for a wake the app shows nothing for, and failed when the work failed.
  • A v1 wake shows nothing by itself on Android. For alert and time_sensitive urgency, post a local notification with the topic's text from wakeListTopics (cached, refreshed now and then), then ack handled.
  • A fallback = true result has nothing to decrypt or ack. The OS shows it in the background. In the foreground it reaches onMessageReceived, where the app has to post its text. A live presence lease keeps most of these away.
  • Treat a wake as a prompt to sync. Take amounts, payment state and contact names from the wallet, never from the payload.
  • Do not do long work in onMessageReceived. Enqueue an expedited worker, as WakeNodeWorker did.
  • The gateway's sample config sends fallback alerts on a wake channel. The app has channel_app and the node channel. Add the channel or change the config so they match.

Presence

  • Call wakeSetPresence on ON_START, renewing every 60 s with a 90 s ttl, and wakeClearPresence on ON_STOP. ContentView.kt already starts and stops Paykit polling at those points.

Unregistering

  • Call wakeUnregister in WipeWalletUseCase.cleanupRemote, which runs before keychain.wipe(). It needs the device secret, and it must stay best effort like the steps around it.

Cleanup

  • Skip registration when Env.isE2eTest is set, as fix: skip push registration in e2e #1245 did, so wiped test installs do not leave dead tokens behind.
  • Stop logging the FCM token in onNewToken. Never log the encryption key, the device secret or a grant token.
  • Fix the stale WakeNodeWorker mention in AGENTS.md.

Phase 2: swap claim wakes

Needs synonymdev/bitkit-core#200, the pubky swaps route (#1732) and resuming swaps in the background (#1835).

  • Owner. After a reverse swap's SwapAccept, if the provider takes a wake capability (swap-wake-v1), call wakeCreateGrant for this wallet's pk: identity with swap.claimable and an expiry that covers the claim window. Hand the token to core to send to the provider. It is shown only once.
  • Revoking. Call wakeRevokeGrant when the swap is claimed or fails. Grants are capped at 30 created per hour per device and 200 live per identity, so keep them per swap and short-lived.
  • Handling swap.claimable. A peer wake has no payload and does not name the swap, and wakes on the topic collapse within 30 s, so one wake can stand for several swaps. In a worker, run pubkySwapsAdvance for every open swap. It checks the lockup on the wallet's own chain view before it claims. Ack handled once each claimable swap has its claim broadcast, and failed otherwise.
  • Without a wake. The topic has no fallback alert, and the gateway holds it back while presence is live (only_if_offline), so keep checking open swaps on launch and in the foreground.

Phase 3: Paykit wakes

Needs the Paykit grant message above.

  • Handling paykit.sync. Run the Paykit-only refresh from feat: notify new payment requests while bitkit is in the background #1350 in a worker, with its notification rules, then ack handled. It does not start the node. Peer wakes carry no payload, so payloadJson is null.
  • Recipient. On linking a peer, call wakeCreateGrant for this wallet's pk: identity with paykit.sync, and send the token over the Encrypted Link. It is shown only once. Keep the grant id per peer, and call wakeRevokeGrant on block or unlink.
  • Reconciling. On launch, use wakeListGrants to check that every linked peer has a live grant with more than 30 days left. Creating grants is limited to 30 per hour per device and 200 live per identity, so do this in batches.
  • Sender. Keep the newest token per link in Keychain. After writing something for a peer, call wakeSendPeerWake as best effort: the write must never depend on it. On grant_invalid, drop the token. On rate_limited, skip the wake.
  • wakeListGrants reports sent_1h and sent_24h, which can show a noisy peer.

Not planned: Lightning node wake

Nothing will send blocktank.* (see Blocked by), so no wake arrives for an incoming HTLC, a timeout or a mutual close. If an LSP sends wakes again, WakeNodeWorker at 25e1aa493, the parent of #1696, is the prior art.

Open questions

  • Who registers? Before feat: blocktank sunset #1696 every wallet registered after node start, while iOS registered only with notifications on. Registering links the node id, the Pubky key and the push token on the gateway. The suggestion is opt-in: only when notifications are granted, with the Background Payments setting turning it on and off. The prompt reaches only Paykit subscribers today (NotificationsTimedSheet), so this needs a decision on widening it.
  • What should Background Payments promise? feat: blocktank sunset #1696 reworded it for Paykit reminders. Wakes add Paykit requests and swap claims, but not Lightning payments that arrive while the app is closed, and the copy should say so.

Done when

  • A wallet registers with ln:, and with pk: when it has one, and registers again after a token rotation with no 409.
  • With the app closed, a wake on a subscribed topic is decrypted once, handled and acked, and wake status shows it acked.
  • Delivering the same wake twice does the work once.
  • A wake past its deadline is dropped.
  • An alert topic posts a notification with the topic's text.
  • After a wipe, the old device secret gets a 401.
  • E2E builds do not register.
  • Unit tests cover parsing, dedupe and ack outcomes, using the shapes in core's src/modules/wake/test_vectors/pushes.json.

Alternatives considered

Not here

  • Pushes for Spark receives. feat: add spark via breez sdk #1697 notes that nothing signals them while the app is closed, and no producer exists for them.
  • Blocktank channel orders.

Additional context

  • API and examples: src/modules/wake/README.md in bitkit-core.
  • Protocol: docs/wire-protocol.md in synonymdev/wake, especially sections 3, 4 and 8. Peer flow: docs/anti-spam.md.
  • Local testing: docker compose -f docker/compose.yml --profile dev up -d --build --wait in synonymdev/wake runs the gateway with fake providers, and wake send, wake status and wake sim drive it. The fakes record pushes rather than deliver them, so a real device needs real Firebase credentials.
  • The committed app/google-services.json is a placeholder. Confirm which Firebase project each build uses, because the gateway needs that project's service account.
  • The v1 wire format stays experimental until the apps adopt it, so problems found here should go back to core and the gateway.
  • fix(wake): close the client gaps from the gateway review bitkit-core#195 added what core was missing: wakeSendTest sends wake.test to this device only (the replacement for the old Dev Settings test button), wakeGetDevice returns what the gateway stored, wakeSetTopics returns unknownTopics, and GatewayRejected carries serverTime and retryAfterSecs.
  • The Spark stack (feat: add spark via breez sdk #1697) touches the same LightningRepo and LightningService code, so expect to rebase over it.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions