# Changes ## v0.1 2026-04-24 - Client can now register multiple subscriptions for different tokens per client id - notificationType (fcm|apns|voip) moved to subscription from rule # Backend Push Notification Service ## Problem Statement The current push notification design (see [original spec](https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/SyPN2yV6lx)) relies on direct peer-to-peer token exchange: peers share push tokens via chat messages, and any peer holding another's token can send push notifications directly. This creates a **spam vulnerability** — once a malicious peer obtains a push token, they can send unlimited unsolicited notifications with no server-side validation or rate control. ### Current Design Flaws | Issue | Description | |-------|-------------| | **Token as capability** | Possessing a push token is the only prerequisite for sending notifications | | **No sender validation** | The relay has no way to verify the sender's identity or intent | | **No topic filtering** | Receiver cannot limit which conversations or topics generate notifications | | **No rate limiting** | No mechanism to throttle abusive senders | | **Token exposure** | Once shared, a token cannot be selectively revoked per-peer | | **Limitation** | No way to be notified when onchain (pallet or smart contract) state changes | --- ## New Design: Backend-Mediated Statement Monitoring ### Core Idea Instead of relaying encrypted blobs from one peer to another, the **backend subscribes to the Statement Store** and monitors new statements. Each client **registers a whitelist** of trusted sender public keys and topics with the backend. When a new statement arrives, the backend **validates the statement signature** and only sends a push notification if the statement matches a registered subscription. Push tokens are **never shared with peers** — only the backend holds them. New design implementation **must extend current API** and **not replace it** to still allow direct p2p notifications ### Design Principles 1. **Push tokens are private** — only the backend and the device owner know them 2. **Sender authentication** — the backend cryptographically verifies every statement's signature before notifying 3. **Receiver consent** — notifications are only sent for whitelisted (pubkey, topic) pairs 4. **Full statement delivery** — push notifications carry the complete statement (SCALE-encoded, still encrypted), so the client can decrypt and display immediately without a round-trip to the statement store 5. **Stateless validation** — the backend does not need to understand message content, only verify signatures and match subscriptions 6. **Dual iOS token support** — distinguishes between APNs (standard alerts) and VoIP (PushKit) tokens for call notifications --- ## Architecture Overview ``` ┌──────────┐ ┌──────────────────┐ ┌─────────────────┐ │ Client A │ │ Statement Store │ │ Client B │ │ (sender) │ │ (blockchain / │ │ (receiver) │ │ │ │ DHT / relay) │ │ │ └─────┬─────┘ └────────┬─────────┘ └────────┬────────┘ │ │ │ │ submit_statement() │ │ │───────────────────────>│ │ │ │ │ │ │ ┌──────────────────┐ │ │ │ │ Push Notification│ │ │ │ │ Backend │ │ │ │ └────────┬─────────┘ │ │ │ │ │ │ │<────────────┤ │ │ │ subscribe/ │ │ │ │ poll topics│ │ │ │ │ │ │ │────────────>│ │ │ │ new statement │ │ │ │ │ │ │ ┌───────┴───────┐ │ │ │ │ 1. Verify sig │ │ │ │ │ 2. Match sub │ │ │ │ │ 3. Rate check │ │ │ │ └───────┬───────┘ │ │ │ │ │ │ │ │ push notif │ │ │ │─────────────>│ │ │ │ │ ``` --- ## iOS Push Token Types: APNs vs VoIP On iOS, two distinct push delivery channels exist with fundamentally different behaviors and OS-level contracts: ### APNs (Standard Push Token) - Obtained via `UIApplication.registerForRemoteNotifications()` - Used for **standard notifications**: message alerts, badge updates - Delivered via Apple Push Notification service (APNs) with topic = app bundle ID (e.g., `io.pcf.polkadotapp`) - Can be displayed as banner/alert after Notification Service Extension (`mutable-content: 1`) processes it - Delivery is best-effort; the OS may throttle or coalesce notifications ### VoIP (PushKit Token) - Obtained via `PKPushRegistry` with type `.voIP` - Used **exclusively for incoming call notifications** - Delivered via APNs with topic = bundle ID + `.voip` suffix (e.g., `com.polkadot.app.voip`) - **iOS guarantees immediate wake-up** and execution time for the app - **Mandatory CallKit reporting**: upon receiving a VoIP push, the app **MUST** report an incoming call to `CallKit` via `CXProvider.reportNewIncomingCall()`. Failure to do so causes iOS to terminate the app and may revoke push privileges - Higher priority and reliability than standard APNs - Cannot be used for non-call content — Apple enforces this policy ### Why Both Are Needed | Scenario | Token Type | Reason | |----------|-----------|--------| | New chat message | APNs | Standard alert notification | | Incoming voice/video call | VoIP | Guaranteed wake-up + CallKit integration required | The backend **must track both tokens independently** per iOS client and dispatch to the correct channel based on the notification type implied by the subscription rule. --- ## Data Models ### Subscription A client's registration with the backend, expressing which (sender, topic) pairs should trigger push notifications. iOS clients register **two tokens** (APNs + VoIP) in different subscriptions; Android clients register one (FCM). ``` ┌──────────────────────────────────────────────┐ │ Subscription │ ├──────────────────────────────────────────────┤ │ id : UUID │ │ clientId : Sr25519PublicKey │ │ notify_type : NotifyType │ │ token : String │ │ created_at : Timestamp │ │ updated_at : Timestamp │ └──────────────────────────────────────────────┘ │ │ 1:N ▼ ┌──────────────────────────────────────────────┐ │ SubscriptionRule │ ├──────────────────────────────────────────────┤ │ id : UUID │ │ subscription_id : UUID (FK) │ │ sender_pubkey : Sr25519PublicKey │ │ topic : Topic (byte array) │ │ created_at : Timestamp │ └──────────────────────────────────────────────┘ ``` **`NotifyType` enum:** `apns` - iOS regular push notifications `voip` - iOS Voice over IP push notifications `fcm` - Android regular push notifications ### Statement (from Statement Store) ``` ┌─────────────────────────────────────────┐ │ Statement │ ├─────────────────────────────────────────┤ │ data : Bytes (SCALE-encoded) │ │ topic1 : [u8; 32] │ │ topic2 : [u8; 32] │ │ topic3 : [u8; 32] │ │ topic4 : [u8; 32] │ │ channel : [u8; 32] │ │ sender_pubkey: Sr25519PublicKey │ │ signature : Sr25519Signature │ │ expiry : u64 │ └─────────────────────────────────────────┘ ``` ### PushRecord (audit / deduplication) ``` ┌─────────────────────────────────────────┐ │ PushRecord │ ├─────────────────────────────────────────┤ │ id : UUID │ │ subscription_id : UUID (FK) │ │ statement_hash : Blake2bHash │ │ sender_pubkey : Sr25519PublicKey │ │ topic : Topic │ │ sent_at : Timestamp │ └─────────────────────────────────────────┘ ``` ### RateLimit ``` ┌─────────────────────────────────────────┐ │ RateLimit │ ├─────────────────────────────────────────┤ │ sender_pubkey : Sr25519PublicKey │ │ client_id : Sr25519PublicKey │ │ window_start : Timestamp │ │ notification_count : u32 │ └─────────────────────────────────────────┘ ``` ## Backend API Client authentication is handled by an existing [solution](https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/r1fAsJF6Wx) and is outside the scope of this document. All endpoints below assume the client is already authenticated and the backend knows the client's public key. --- ### `POST /v1/subscriptions` Registers the client's push token with given type. **Request Body (iOS):** ```json { "notificationType": "apns", "token": "hex-encoded-apns-device-token" } ``` **Request Body (Android):** ```json { "platform": "fcm", "token": "fcm-device-token" } ``` **Response `201 Created`:** ```json { "subscription_id": "uuid" } ``` **Behavior:** Creates based on `client_id`. If a subscription already exists for this token then an error must be returned. Given client id MAY have several subscriptions for different tokens. --- ### `DELETE /v1/subscriptions` Removes subscriptions with given ids and all associated rules. **Request Body (Android):** ```json { "subscription_ids": [UUID] } ``` **Response `204 No Content`** --- ### `PUT /v1/subscriptions/rules` Replace the entire rule set for the client's subscription. This is the mechanism for whitelisting (sender_pubkey, topic, notify_type) tuples. **Request Body:** ```json { "subscription_id": UUID, "rules": [ { "sender_pubkey": "ab12cd34...", "topic": "e5f6a7b8..." }, { "sender_pubkey": "ab12cd34...", "topic": "f1a2b3c4..." }, { "sender_pubkey": "cd34ef56...", "topic": "a1b2c3d4..." } ] } ``` **Response `204 No Content`** **Behavior:** Atomic replace — deletes all existing rules and inserts the new set. **Note:** The same `(sender_pubkey, topic)` can't duplicate for given subscription. --- ### `POST /v1/subscriptions/rules` Add individual rules without replacing existing ones. **Request Body:** ```json { "subscription_id": UUID, "rules": [ { "sender_pubkey": "ab12cd34...", "topic": "e5f6a7b8..." } ] } ``` **Response `201 Created`:** ```json { "added": 1, "total_rules": 4 } ``` --- ### `DELETE /v1/subscriptions/rules` Remove specific rules. Matches on `(sender_pubkey, topic)` tuple. **Request Body:** ```json { "subscription_id": UUID, "rules": [ { "sender_pubkey": "ab12cd34...", "topic": "e5f6a7b8..." } ] } ``` **Response `200 OK`:** ```json { "removed": 1, "total_rules": 3 } ``` --- ### `GET /v1/subscriptions` Retrieve the client's current subscriptions and rules. **Response `200 OK`:** ```json [ { "subscription_id": UUID, "token": <hex>, "rules": [ { "sender_pubkey": "ab12cd34...", "topic": "e5f6a7b8..." }, { "sender_pubkey": "ab12cd34...", "topic": "f1a2b3c4..." }, { "sender_pubkey": "cd34ef56...", "topic": "a1b2c3d4..." } ] } ] ``` --- ## Sequence Diagrams ### 1. Client Registration and Subscription Setup ``` ┌────────┐ ┌─────────┐ │Client B│ │ Backend │ └───┬────┘ └────┬────┘ │ │ │ POST /v1/subscriptions │ │ [authenticated] │ │─────────────────────────────────>│ │ │ │ ┌───────┴───────┐ │ │ Auth check │ │ │ Upsert sub │ │ └───────┬───────┘ │ │ │ 201 { subscription_id } │ │<─────────────────────────────────│ │ │ │ PUT /v1/subscriptions/rules │ │ { rules: [ │ │ {sender: A_pub, topic: T1}, │ │ {sender: A_pub, topic: T2}, │ │ {sender: C_pub, topic: T3} │ │ ]} │ │ [authenticated] │ │─────────────────────────────────>│ │ │ │ ┌───────┴───────┐ │ │ Auth check │ │ │ Replace rules │ │ │ Subscribe to │ │ │ T1, T2, T3 │ │ └───────┬───────┘ │ │ │ 200 { rules_count: 3 } │ │<─────────────────────────────────│ │ │ ``` ### 2. Statement Submission and Push Notification Delivery ``` ┌────────┐ ┌──────────────┐ ┌─────────┐ ┌────────┐ │Client A│ │Statement Store│ │ Backend │ │Client B│ │(sender)│ │ │ │ │ │(receiver) └───┬────┘ └──────┬───────┘ └────┬────┘ └───┬────┘ │ │ │ │ │ submit_statement( │ │ │ │ data, topic T1, │ │ │ │ sig(A_priv)) │ │ │ │─────────────────────>│ │ │ │ │ │ │ │ │ new statement │ │ │ │ event on topic T1 │ │ │ │───────────────────>│ │ │ │ │ │ │ │ ┌───────┴────────┐ │ │ │ │ 1. Verify sig │ │ │ │ │ Sr25519( │ │ │ │ │ A_pub, │ │ │ │ │ statement) │ │ │ │ │ │ │ │ │ │ 2. Lookup rules│ │ │ │ │ matching │ │ │ │ │ (A_pub, T1) │ │ │ │ │ │ │ │ │ │ 3. Found: B's │ │ │ │ │ subscription│ │ │ │ │ │ │ │ │ │ 4. Rate limit │ │ │ │ │ check OK │ │ │ │ │ │ │ │ │ │ 5. Deduplicate │ │ │ │ │ (stmt hash) │ │ │ │ └───────┬────────┘ │ │ │ │ │ │ │ │ Push notification│ │ │ │ { statement: │ │ │ │ {data,topic, │ │ │ │ sig,nonce, │ │ │ │ timestamp, │ │ │ │ sender_pub} } │ │ │ │──────────────────>│ │ │ │ │ │ │ │ ┌───────┴──────┐ │ │ │ │ Receive push │ │ │ │ │ Decrypt stmt │ │ │ │ │ data locally │ │ │ │ │ using K(A,B) │ │ │ │ │ Display msg │ │ │ │ └───────┬──────┘ │ │ │ │ ``` ### 3. VoIP Call Notification (iOS) ``` ┌────────┐ ┌──────────────┐ ┌─────────┐ ┌────────────┐ │Client A│ │Statement Store│ │ Backend │ │ Client B │ │(caller)│ │ │ │ │ │ (iOS callee) └───┬────┘ └──────┬───────┘ └────┬────┘ └─────┬──────┘ │ │ │ │ │ submit_statement( │ │ │ │ call_offer, │ │ │ │ topic T_call, │ │ │ │ sig(A_priv)) │ │ │ │─────────────────────>│ │ │ │ │ │ │ │ │ new statement │ │ │ │ on T_call │ │ │ │───────────────────>│ │ │ │ │ │ │ │ ┌───────┴────────┐ │ │ │ │ 1. Verify sig │ │ │ │ │ 2. Match rule: │ │ │ │ │ (A_pub,T_call│ │ │ │ │ voip) │ │ │ │ │ 3. Select │ │ │ │ │ voip_token │ │ │ │ └───────┬────────┘ │ │ │ │ │ │ │ │ APNs VoIP push │ │ │ │ topic:bundle.voip │ │ │ │ { full statement } │ │ │ │────────────────────>│ │ │ │ │ │ │ │ ┌───────┴───────┐ │ │ │ │ PushKit │ │ │ │ │ didReceive │ │ │ │ │ │ │ │ │ │ MUST call │ │ │ │ │ CXProvider. │ │ │ │ │ reportNew │ │ │ │ │ IncomingCall()│ │ │ │ │ │ │ │ │ │ Decrypt call │ │ │ │ │ offer from │ │ │ │ │ stmt.data │ │ │ │ │ │ │ │ │ │ Display │ │ │ │ │ CallKit UI │ │ │ │ │ with caller │ │ │ │ │ name from │ │ │ │ │ A_pub lookup │ │ │ │ └───────┬───────┘ │ │ │ │ ``` ### 4. Signature Verification Failure (Rejected Statement) ``` ┌──────────┐ ┌──────────────┐ ┌─────────┐ ┌────────┐ │ Malicious│ │Statement Store│ │ Backend │ │Client B│ │ Peer │ │ │ │ │ │ │ └────┬─────┘ └──────┬───────┘ └────┬────┘ └───┬────┘ │ │ │ │ │ submit_statement( │ │ │ │ data, topic T1, │ │ │ │ forged_sig) │ │ │ │───────────────────>│ │ │ │ │ │ │ │ │ new statement │ │ │ │ event on topic T1 │ │ │ │───────────────────>│ │ │ │ │ │ │ │ ┌───────┴────────┐ │ │ │ │ 1. Verify sig │ │ │ │ │ FAILED! │ │ │ │ │ │ │ │ │ │ 2. Drop stmt │ │ │ │ │ Log event │ │ │ │ └───────┬────────┘ │ │ │ │ │ │ │ (no push sent) │ │ │ │ │ ``` ### 5. Unwhitelisted Sender (Rejected by Rule Match) ``` ┌────────┐ ┌──────────────┐ ┌─────────┐ ┌────────┐ │Client X│ │Statement Store│ │ Backend │ │Client B│ │(unknown)│ │ │ │ │ │ │ └───┬────┘ └──────┬───────┘ └────┬────┘ └───┬────┘ │ │ │ │ │ submit_statement( │ │ │ │ data, topic T1, │ │ │ │ sig(X_priv)) │ │ │ │─────────────────────>│ │ │ │ │ │ │ │ │ new statement │ │ │ │───────────────────>│ │ │ │ │ │ │ │ ┌───────┴────────┐ │ │ │ │ 1. Verify sig │ │ │ │ │ OK (valid) │ │ │ │ │ │ │ │ │ │ 2. Lookup rules│ │ │ │ │ matching │ │ │ │ │ (X_pub, T1) │ │ │ │ │ │ │ │ │ │ 3. No match! │ │ │ │ │ B has not │ │ │ │ │ whitelisted │ │ │ │ │ X_pub │ │ │ │ │ │ │ │ │ │ 4. Drop │ │ │ │ └───────┬────────┘ │ │ │ │ │ │ │ (no push sent) │ │ │ │ │ ``` --- ## Backend Internal Processing Pipeline ``` ┌──────────────────────────────────────────────────────────────────────┐ │ Backend Processing Pipeline │ │ │ │ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │ │ │ Statement │ │ Signature │ │ Rule │ │ │ │ Ingestion │───>│ Verification│───>│ Matching │ │ │ │ │ │ │ │ │ │ │ │ Subscribe to │ │ Sr25519 │ │ SELECT subs │ │ │ │ statement │ │ verify( │ │ WHERE sender= │ │ │ │ store events │ │ sender_pub, │ │ AND topic= │ │ │ │ │ │ statement) │ │ │ │ │ └─────────────┘ └──────┬───────┘ └───────┬───────┘ │ │ │ FAIL │ │ │ ▼ ▼ │ │ ┌─────────┐ ┌───────────────┐ │ │ │ DROP │ │ Rate Limit │ │ │ │ +LOG │ │ Check │ │ │ └─────────┘ │ │ │ │ │ per (sender, │ │ │ │ client) pair │ │ │ └───────┬───────┘ │ │ │ │ │ ▼ │ │ ┌───────────────────┐ │ │ │ Deduplication │ │ │ │ │ │ │ │ Check stmt_hash │ │ │ │ not already sent │ │ │ └───────┬───────────┘ │ │ │ │ │ │ │ │ ▼ │ │ ┌─────────────────────┐ │ │ │ Push Dispatch │ │ │ │ (by notify_type │ │ │ │ + platform) │ │ │ └──────────┬──────────┘ │ │ │ │ │ ┌────────────────┼────────────────┐ │ │ ▼ ▼ ▼ │ │ ┌────────────┐ ┌──────────────┐ ┌──────────┐ │ │ │ APNs Alert │ │ APNs VoIP │ │ FCM │ │ │ │ │ │ │ │ │ │ │ │ iOS alert │ │ iOS voip │ │ Android │ │ │ │ apns_token │ │ voip_token │ │ fcm_token│ │ │ │ bundle.id │ │ bundle.voip │ │ alert or │ │ │ │ │ │ │ │ voip type│ │ │ └────────────┘ └──────────────┘ └──────────┘ │ │ │ └──────────────────────────────────────────────────────────────────────┘ ``` --- ## Push Notification Payload (New Design) The backend sends the **full statement** in the push notification payload. The statement data remains encrypted (SCALE-encoded) — the backend cannot read it, but the client can decrypt and display the content immediately without a second round-trip to the statement store. The delivery channel and payload structure differ based on `notify_type` and platform. ### Payload Size Constraints | Platform | Channel | Max payload | Encoding overhead | |----------|---------|-------------|-------------------| | iOS | APNs | 4 KB | ~100 bytes for `aps` dict + keys | | iOS | VoIP (PushKit) | 5 KB | ~50 bytes for `aps` dict + keys | | Android | FCM data message | 4 KB | ~80 bytes for wrapper + keys | The statement fields (`data`, `topic`, `sender_pubkey`) are hex-encoded in the payload, which doubles their byte size. The backend **must check** the encoded payload size before sending. If the full statement exceeds the platform limit, the backend sends a **truncated payload** containing only the metadata (topic, sender_pubkey) without the statement body, so the client knows that if data field is missing then they should fetch it from the store. ### iOS — APNs Alert (notify_type: "alert") Sent via standard APNs using the `apns_token`. APNs topic = app bundle ID. **Full statement fits (common case):** ```json { "aps": { "alert": { "title": "Polkadot App" }, "mutable-content": 1 }, "statement": { "data": "<SCALE-encoded encrypted data, hex>", "topic": "<topic hex>", "sender_pubkey": "<sender pubkey hex>" } } ``` **Statement exceeds payload limit (fallback):** ```json { "aps": { "alert": { "title": "Polkadot" }, "mutable-content": 1, "content-available": 1 }, "statement": { "data": null, "topic": "<topic hex>", "sender_pubkey": "<sender pubkey hex>" } } ``` - `mutable-content: 1` triggers the Notification Service Extension, allowing the app to decrypt the statement data (if present) or fetch it from the store (if truncated), and modify the notification content before display - `content-available: 1` is added only for truncated payloads, waking the app to fetch the full statement ### iOS — VoIP Push (notify_type: "voip") Sent via APNs using the `voip_token`. APNs topic = bundle ID + `.voip` suffix. Uses PushKit delivery. ```json { "aps": {}, "statement": { "data": "<SCALE-encoded encrypted data, hex>", "topic": "<topic hex>", "sender_pubkey": "<sender pubkey hex>" } } ``` **Critical constraint:** Upon receiving this push, the app **MUST** call `CXProvider.reportNewIncomingCall()` to present the CallKit incoming call UI. If the app fails to do so, iOS will: 1. Terminate the app 2. Stop delivering VoIP pushes after repeated violations The `aps` dictionary is intentionally empty — VoIP pushes do not display alerts; the CallKit UI handles the user-facing presentation. **APNs headers for VoIP:** ``` apns-topic: io.pcf.polkadotapp.voip apns-push-type: voip apns-priority: 10 apns-expiration: 0 ``` ### Android — FCM (both notify_types) Android uses FCM for all notification types. The `notify_type` field in the data payload tells the client how to handle it. **Alert:** ```json { "data": { "statement_data": "<SCALE-encoded encrypted data, hex>", "statement_topic": "<topic hex>", "sender_pubkey": "<sender pubkey hex>" }, "android": { "priority": "high" } } ``` Note: FCM data payloads only support string values, so numeric fields and booleans are string-encoded. On Android, the client handles `voip` type by displaying a full-screen incoming call notification via `NotificationManager` with `CATEGORY_CALL` and `FULL_SCREEN_INTENT` for the lock screen experience. ### Client-side Handling on Push Receipt **Alert (message) flow:** 1. Receive push with full statement and `notify_type: "alert"` 2. Resolve contact by `sender_pubkey` 3. If `truncated: false` — decrypt `statement.data` using shared key `K(A,B)` and display message content 4. If `truncated: true` — fetch full statement from statement store for the given `topic`, then decrypt and display 5. Optionally verify the statement signature client-side for additional assurance **VoIP (call) flow — iOS:** 1. `PKPushRegistry` delegate receives push in `pushRegistry(_:didReceiveIncomingPushWith:for:completion:)` 2. **Immediately** report incoming call to CallKit: `CXProvider.reportNewIncomingCall()` 3. Extract full statement from push payload 4. Resolve contact by `sender_pubkey` to display caller name 5. Decrypt call offer from `statement.data` 6. Establish call session using the decrypted call parameters **VoIP (call) flow — Android:** 1. `FirebaseMessagingService.onMessageReceived()` receives data message 2. Check `notify_type == "voip"` 3. Display full-screen incoming call notification 4. Extract full statement, resolve contact by `sender_pubkey` 5. On user answer — decrypt call offer and establish call session --- ## Signature Verification Detail The backend performs Sr25519 signature verification on every incoming statement. The backend does **not** decrypt the statement data. It only verifies that the claimed sender actually signed it. --- ## Push Dispatch Decision Logic When a statement matches one or more subscription rules, the backend selects the delivery channel based on `platform` and `notify_type`: ``` fn dispatch(subscription: &Subscription, rule: &SubscriptionRule, statement: &Statement) { let max_payload = match (subscription.platform, rule.notify_type) { (iOS, Alert) => 4096, (iOS, VoIP) => 5120, (Android, _) => 4096, }; // Build full payload with statement included let full_payload = build_payload(statement, rule.notify_type, false); let (payload, truncated) = if full_payload.len() <= max_payload { (full_payload, false) } else { // Fallback: metadata only, no statement body (build_payload(statement, rule.notify_type, true), true) }; match (subscription.platform, rule.notify_type) { (iOS, Alert) => { assert(subscription.apns_token.is_some()); send_apns( token: subscription.apns_token, apns_topic: BUNDLE_ID, // "com.polkadot.app" push_type: "alert", priority: 10, payload: payload, ); } (iOS, VoIP) => { assert(subscription.voip_token.is_some()); send_apns( token: subscription.voip_token, apns_topic: BUNDLE_ID + ".voip", // "com.polkadot.app.voip" push_type: "voip", priority: 10, expiration: 0, // immediate delivery only payload: payload, ); } (Android, _) => { assert(subscription.fcm_token.is_some()); send_fcm( token: subscription.fcm_token, priority: "high", payload: payload, ); } } } ``` **Payload truncation:** When the hex-encoded statement exceeds the platform payload limit, the backend omits the `statement` object and sets `truncated: true`. The client then falls back to fetching the statement from the store. In practice, most chat messages and call offers are small enough to fit within the 4KB limit (~1.9KB of raw data after hex encoding and JSON overhead). --- ## Rate Limiting The backend enforces per-(sender, receiver) rate limits using a sliding window: | Parameter | Value | |-----------|-------| | Window size | 60 seconds | | Max notifications per window | 30 | | Cooldown on exceed | 120 seconds | When the rate limit is exceeded, the backend silently drops notifications from that sender to that receiver for the cooldown period. No error is surfaced to the sender (they cannot distinguish between a delivered and dropped notification). --- ## Comparison: Current vs. New Design | Aspect | Current Design | New Design | |--------|---------------|------------| | **Token sharing** | Peers exchange push tokens directly via chat | Push tokens are registered only with the backend; peers never see them | | **Notification trigger** | Sender explicitly calls `notify(pushToken, ...)` | Backend monitors statement store; notifications are triggered by new statements | | **Sender authentication** | None — possessing a token is sufficient | Sr25519 signature verification on every statement | | **Receiver consent** | Implicit (shared the token) | Explicit whitelist of `(sender_pubkey, topic)` pairs | | **Spam protection** | None | Whitelist + signature verification + rate limiting | | **Payload content** | Full encrypted message in push payload | Full statement (encrypted data + signature + metadata) in push payload; fallback to topic-only signal if statement exceeds platform size limit | | **Token revocation** | Must rotate token and re-share with all peers | Remove sender from whitelist rules — instant, per-peer granularity | | **Privacy** | Push token exposed to peers; PushId derivation needed | Push token never leaves the client-backend relationship | | **Encryption** | Message encrypted end-to-end in push payload | Statement data remains encrypted end-to-end; backend forwards it opaquely without decryption | | **Backend complexity** | Simple relay (stateless) | Statement store subscriber + subscription management + signature verification | | **Offline delivery** | Direct push with encrypted content | Full statement delivered in push; client decrypts immediately on wake | | **Deduplication** | None (same message can trigger multiple pushes) | Statement hash tracking prevents duplicate notifications | | **Bandwidth** | Full message in push payload (limited by APNs/FCM size) | Full statement in push payload (same size limit); graceful truncation fallback for oversized statements | | **Scalability risk** | Each peer must manage tokens for all contacts | Backend manages subscriptions centrally; can shard by topic | ### Key Advantages of the New Design 1. **Eliminates spam vector**: A peer cannot send push notifications directly. The backend only notifies based on verified statements matching explicit subscriptions. 2. **Granular control**: Clients can whitelist/blacklist individual senders per topic without rotating push tokens or affecting other contacts. 3. **Signature-based trust**: Every notification is backed by a cryptographically verified statement. Forged or unsigned statements are silently dropped. 4. **Token privacy**: Push tokens never leave the client-backend channel, eliminating the entire class of token-theft attacks. 5. **Immediate content availability**: The full statement is delivered in the push payload, so the client can decrypt and display the message immediately — no round-trip to the statement store needed. For the rare case where a statement exceeds platform payload limits (~1.9KB usable after hex encoding), the client gracefully falls back to fetching from the store. ### Trade-offs 1. **Backend becomes stateful**: The backend must maintain subscription state, monitor the statement store, and run signature verification. This increases operational complexity. 2. **Payload size limit**: The full statement is included in the push payload, which is subject to platform limits (4KB APNs, 5KB VoIP PushKit, 4KB FCM). Hex encoding doubles the raw byte size, leaving ~1.9KB of usable statement data. Oversized statements fall back to a truncated payload requiring a store fetch, but typical chat messages and call offers are well within this limit. 3. **Statement store dependency**: The backend must be connected to the statement store to receive new statements. If the store is unavailable, no push notifications are sent. However, the client does not depend on the store for most notifications — the full statement arrives in the push itself. 4. **Backend trust**: The client must trust the backend to correctly verify signatures and honor the whitelist. A compromised backend could send spurious notifications (though not forge message content, since content is fetched and decrypted client-side from the statement store).