Push notifications without a client-to-server channel: design, trade-offs, and a known deregistration race #72
adamantmm
started this conversation in
Ecosystem & Integrations
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Push notifications are the one place where a private messenger is most tempted to compromise. Somebody has to be told that a message arrived, and on iOS that somebody is Apple. This post describes how ADAMANT wires that up so the notification service learns as little as possible, what the design costs us, and one known failure mode we have decided to live with rather than paper over.
The shape of the system
Four parties are involved: the user's device, an ADAMANT node, Apple Push Notification service (APNs), and the ADAMANT Notification Service (ANS), which is the piece we run.
Registration goes through the blockchain, not through an API:
{token, provider, action}to the ANS public key and sends it as a signal message (chat type 3, AIP-6) to the ANS account's ADM address, through whichever node the user has chosen.ADM address → push tokenpair.Delivery is the mirror image. ANS polls for transfer (type 0) and chat (type 8) transactions, checks the recipient against its registry, and asks APNs to deliver a push.
What each party actually learns
The notification payload carries no message content:
{ "aps": { "alert": { "loc-key": "NotificationsService.NewMessage.BodySingle" }, "badge": 1, "mutable-content": 1, "sound": "notification.mp3" }, "push-recipient": "U1234567890123456", "txn-id": "7175005690801347553" }The body is a localization key, not a message. The app's Notification Service Extension takes
txn-id, fetches the transaction from a node, and decrypts it locally with the user's private key before the notification is rendered. So Apple sees a device token, an ADM address, a transaction id, and timing — never content. That is a real disclosure and worth stating plainly rather than glossing over: if you push to Apple, Apple learns that this address received something, and when.The more interesting property is on our side. The app never opens a connection to ANS. We verified this again while working on the service: the iOS client's token service issues exactly two kinds of network calls, both to ADM nodes, and no push-service hostname appears anywhere in the app. ANS therefore sees only what is already public on-chain and, critically, never sees a device IP address. A user routing through their own node or through Tor does not touch our infrastructure at all.
That property is the whole point, and it is fragile. The obvious convenience feature — a small HTTPS endpoint on ANS so a client could ask "do you still have my token?" — would quietly destroy it. It would hand ANS the device IP, turn every app launch into a per-device liveness signal, defeat the user's choice of node, and create a single blockable hostname where today there is a pluggable node list. We are not adding that endpoint.
The registration lifecycle, and where it bites
Because the channel is the blockchain,
addandremoveare transactions. Each signal message costs a normal chat fee:constants.fees.chat_message = 100000atfixedPoint = 1e8, i.e. 0.001 ADM. That matters more than the price suggests — a user with a zero balance cannot send one at all, which rules out "just re-register periodically" as a robustness strategy.The client keeps a local copy of the token it believes is registered and only re-registers when iOS hands it a different token. It never asks the server whether the registration still exists, because, as above, it cannot ask without giving up the privacy property.
This produces a known and reproducible failure mode that requires no data loss on the server:
remove(T). The send fails, so the transaction is persisted and retried on every subsequent app launch.T. The local cache is empty, so the client sendsadd(T); it succeeds. Server state is now correct.remove(T)eventually succeeds. It lands on chain after theadd, so ANS deletes the registration it just created.T, iOS keeps supplyingT, so the "did the token change?" check never fires. The device is unregistered and has no way to notice.Notifications stop silently. The only recovery is a manual Notifications → Off → Push in the app, which clears the local cache and forces a fresh registration.
Why we are not rushing this
It is a real bug, but a narrow one: it needs a failed unregister followed by a re-registration of the same token. The two shortcuts that would mask it are both worse than the bug. An HTTP check trades a rare silent failure for a permanent, universal metadata leak. Periodic re-registration spends user funds on a schedule and simply does not work for anyone holding zero ADM.
The correct fix is client-side ordering, and it stays entirely within the blockchain-mediated design: make the retry queue token-aware so a pending
remove(T)is dropped once a lateradd(T)succeeds, stop treating "cache is empty" as the registration state, and persist theaddwith the same care theremovealready gets. That work belongs in the clients.A related consequence of the same asymmetry: an
unregistercan only be sent for a token the app still remembers, so a reinstall orphans the previous registration permanently. Our registry currently holds around 2,600 rows across roughly 1,700 distinct addresses, with one address carrying 251 of them. Rows are removed when APNs reports a token as dead, so the registry does self-clean for anyone still receiving pushes — but an address nobody writes to is never exercised and never cleaned.If you are building on this
Two takeaways for anyone integrating with ADAMANT notifications, or building a client:
Questions, corrections, and war stories from anyone who has solved the ordering problem more elegantly are all welcome.
All reactions