Skip to main content

host_api/
lib.rs

1//! Host API — transport-agnostic engine for the Polkadot app host-api protocol.
2//!
3//! Processes binary SCALE-encoded messages from Polkadot apps and returns binary
4//! responses. Works over any transport: WKWebView MessagePort, PolkaVM host calls,
5//! WebSocket, etc.
6//!
7//! See [`HostApi::handle_message`] for the main entry point.
8
9pub mod chain;
10pub mod codec;
11pub mod protocol;
12
13use protocol::{
14    decode_message, encode_account_status, encode_feature_response, encode_response, Account,
15    HostRequest, HostResponse, PROTOCOL_VERSION, TAG_ACCOUNT_STATUS_INTERRUPT,
16    TAG_ACCOUNT_STATUS_STOP, TAG_CHAIN_HEAD_BODY_REQ, TAG_CHAIN_HEAD_CALL_REQ,
17    TAG_CHAIN_HEAD_CONTINUE_REQ, TAG_CHAIN_HEAD_FOLLOW_INTERRUPT, TAG_CHAIN_HEAD_FOLLOW_STOP,
18    TAG_CHAIN_HEAD_HEADER_REQ, TAG_CHAIN_HEAD_STOP_OP_REQ, TAG_CHAIN_HEAD_STORAGE_REQ,
19    TAG_CHAIN_HEAD_UNPIN_REQ, TAG_CHAIN_SPEC_GENESIS_REQ, TAG_CHAIN_SPEC_NAME_REQ,
20    TAG_CHAIN_SPEC_PROPS_REQ, TAG_CHAT_ACTION_INTERRUPT, TAG_CHAT_ACTION_STOP,
21    TAG_CHAT_CUSTOM_MSG_INTERRUPT, TAG_CHAT_CUSTOM_MSG_STOP, TAG_CHAT_LIST_INTERRUPT,
22    TAG_CHAT_LIST_STOP, TAG_JSONRPC_SUB_INTERRUPT, TAG_JSONRPC_SUB_STOP,
23    TAG_PREIMAGE_LOOKUP_INTERRUPT, TAG_PREIMAGE_LOOKUP_STOP, TAG_STATEMENT_STORE_INTERRUPT,
24    TAG_STATEMENT_STORE_STOP,
25};
26
27#[cfg(feature = "tracing")]
28use tracing::info_span;
29
30/// Maximum size of a single storage value (64 KB).
31const MAX_STORAGE_VALUE_SIZE: usize = 64 * 1024;
32/// Maximum storage key length (512 bytes).
33const MAX_STORAGE_KEY_LENGTH: usize = 512;
34/// Maximum push notification text length in UTF-8 bytes (1024 bytes).
35/// Multi-byte characters count proportionally to their encoded size.
36const MAX_PUSH_NOTIFICATION_TEXT_LEN: usize = 1024;
37/// Maximum deeplink URL length in UTF-8 bytes (2048 bytes).
38const MAX_DEEPLINK_URL_LEN: usize = 2048;
39/// Allowed URI scheme prefixes for push notification deeplinks.
40/// The SDK rejects any deeplink that does not start with one of these
41/// schemes. Only `https://` is permitted.
42const ALLOWED_DEEPLINK_SCHEMES: &[&str] = &["https://"];
43
44/// Outcome of processing a host-api message.
45#[derive(serde::Serialize)]
46#[serde(tag = "type")]
47pub enum HostApiOutcome {
48    /// Send this response directly back to the app.
49    Response { data: Vec<u8> },
50    /// Sign request — needs wallet to produce a signature before responding.
51    NeedsSign {
52        request_id: String,
53        request_tag: u8,
54        public_key: Vec<u8>,
55        payload: Vec<u8>,
56    },
57    /// JSON-RPC query — needs routing through the chain API allowlist + RPC bridge.
58    NeedsChainQuery {
59        request_id: String,
60        method: String,
61        params: serde_json::Value,
62    },
63    /// JSON-RPC subscription — needs routing through the chain API for streaming responses.
64    NeedsChainSubscription {
65        request_id: String,
66        method: String,
67        params: serde_json::Value,
68    },
69    /// Navigation request — the workbench should open this URL (may be a .dot address).
70    NeedsNavigate { request_id: String, url: String },
71    /// Push notification — host should display a toast/banner with the given text.
72    NeedsPushNotification {
73        request_id: String,
74        text: String,
75        deeplink: Option<String>,
76    },
77    /// Start a chainHead_v1_follow subscription for a specific chain.
78    NeedsChainFollow {
79        request_id: String,
80        genesis_hash: Vec<u8>,
81        with_runtime: bool,
82    },
83    /// A chain interaction request (header, storage, call, etc.) that needs
84    /// routing to smoldot via JSON-RPC. The response_tag and json_rpc_method
85    /// tell the workbench how to route and encode the response.
86    NeedsChainRpc {
87        request_id: String,
88        request_tag: u8,
89        genesis_hash: Vec<u8>,
90        json_rpc_method: String,
91        json_rpc_params: serde_json::Value,
92        /// The follow subscription ID from the product-SDK (opaque string).
93        follow_sub_id: Option<String>,
94    },
95    /// Product-scoped storage read — the host must look up `key` in persistent
96    /// storage for the current product and call `encode_storage_read_response`.
97    NeedsStorageRead { request_id: String, key: String },
98    /// Product-scoped storage write — the host must persist `value` under `key`
99    /// for the current product and call `encode_storage_write_response`.
100    NeedsStorageWrite {
101        request_id: String,
102        key: String,
103        value: Vec<u8>,
104    },
105    /// Product-scoped storage clear — the host must remove `key` from persistent
106    /// storage for the current product and call `encode_storage_clear_response`.
107    NeedsStorageClear { request_id: String, key: String },
108    /// No response needed (fire-and-forget).
109    Silent,
110}
111
112/// Shared host implementation. Handles decoded requests, returns encoded responses.
113pub struct HostApi {
114    accounts: Vec<Account>,
115    supported_chains: std::collections::HashSet<[u8; 32]>,
116}
117
118impl Default for HostApi {
119    fn default() -> Self {
120        Self::new()
121    }
122}
123
124impl HostApi {
125    pub fn new() -> Self {
126        Self {
127            accounts: Vec::new(),
128            supported_chains: std::collections::HashSet::new(),
129        }
130    }
131
132    /// Set the accounts that will be returned by host_get_non_product_accounts.
133    ///
134    /// SECURITY: This exposes account identifiers to any product that requests
135    /// them. Will be replaced by scoped per-product identities via host-sso.
136    pub fn set_accounts(&mut self, accounts: Vec<Account>) {
137        self.accounts = accounts;
138    }
139
140    /// Set the chain genesis hashes that this host supports.
141    /// Each hash is 32 bytes (raw, not hex-encoded).
142    pub fn set_supported_chains(&mut self, chains: impl IntoIterator<Item = [u8; 32]>) {
143        self.supported_chains = chains.into_iter().collect();
144    }
145
146    /// Process a raw binary message from the app.
147    ///
148    /// Returns `HostApiOutcome::Response` for immediate replies,
149    /// `HostApiOutcome::NeedsSign` for sign requests that need wallet approval,
150    /// or `HostApiOutcome::Silent` for fire-and-forget messages.
151    ///
152    /// `app_id` identifies the product — the host must use this value to scope
153    /// storage when acting on `NeedsStorageRead`, `NeedsStorageWrite`, and
154    /// `NeedsStorageClear` outcomes. It is not embedded in the outcome itself.
155    pub fn handle_message(&mut self, raw: &[u8], app_id: &str) -> HostApiOutcome {
156        #[cfg(feature = "tracing")]
157        let _span = info_span!(
158            "host_api.handle_message",
159            app_id,
160            request_tag = tracing::field::Empty,
161            request_kind = tracing::field::Empty,
162        )
163        .entered();
164        let _ = app_id; // used by tracing; host reads it from call-site context
165
166        let (request_id, request_tag, req) = match decode_message(raw) {
167            Ok(v) => v,
168            Err(e) => {
169                log::warn!(
170                    "[hostapi] failed to decode message: kind={}",
171                    decode_error_kind(&e)
172                );
173                #[cfg(feature = "tracing")]
174                tracing::Span::current().record("request_kind", "decode_error");
175                return HostApiOutcome::Silent;
176            }
177        };
178
179        #[cfg(feature = "tracing")]
180        {
181            tracing::Span::current().record("request_tag", request_tag);
182            tracing::Span::current().record("request_kind", request_kind(&req));
183        }
184
185        log::info!(
186            "[hostapi] request: kind={} (tag={request_tag})",
187            request_kind(&req)
188        );
189
190        match req {
191            HostRequest::Handshake { version } => {
192                if version == PROTOCOL_VERSION {
193                    HostApiOutcome::Response {
194                        data: encode_response(&request_id, request_tag, &HostResponse::HandshakeOk),
195                    }
196                } else {
197                    log::warn!("[hostapi] unsupported protocol version: {version}");
198                    HostApiOutcome::Response {
199                        data: encode_response(
200                            &request_id,
201                            request_tag,
202                            &HostResponse::Error("unsupported protocol version".into()),
203                        ),
204                    }
205                }
206            }
207
208            HostRequest::GetNonProductAccounts => {
209                // SECURITY: This exposes the soft-derivation root (//wallet//sso
210                // pubkey) to any product that requests it, allowing enumeration
211                // of all product addresses. This will be replaced by the host-sso
212                // pairing flow which provides scoped, per-product identities.
213                // See docs/threat-model-soft-derivation.md scenarios 1 & 2.
214                // TODO: remove once host-sso is fully wired and VoxChat uses it.
215                log::info!(
216                    "[hostapi] returning {} non-product account(s)",
217                    self.accounts.len()
218                );
219                HostApiOutcome::Response {
220                    data: encode_response(
221                        &request_id,
222                        request_tag,
223                        &HostResponse::AccountList(self.accounts.clone()),
224                    ),
225                }
226            }
227
228            HostRequest::FeatureSupported { feature_data } => {
229                let feature_kind = feature_log_kind(&feature_data);
230                // Check string features first (signing, navigate, etc.)
231                let supported = if let Ok(s) = std::str::from_utf8(&feature_data) {
232                    let r = matches!(s, "signing" | "sign" | "navigate" | "push_notification");
233                    r
234                } else {
235                    // Binary feature: byte 0 = type, then SCALE-encoded data.
236                    // Type 0 = chain: compact_length + genesis_hash (32 bytes)
237                    let r = if feature_data.first() == Some(&0) {
238                        codec::Reader::new(&feature_data[1..])
239                            .read_var_bytes()
240                            .ok()
241                            .and_then(|h| <[u8; 32]>::try_from(h).ok())
242                            .map(|h| self.supported_chains.contains(&h))
243                            .unwrap_or(false)
244                    } else {
245                        false
246                    };
247                    r
248                };
249                log::info!("[hostapi] feature_supported: feature={feature_kind} -> {supported}");
250                HostApiOutcome::Response {
251                    data: encode_feature_response(&request_id, supported),
252                }
253            }
254
255            HostRequest::AccountConnectionStatusStart => HostApiOutcome::Response {
256                data: encode_account_status(&request_id, true),
257            },
258
259            HostRequest::LocalStorageRead { key } => {
260                if key.len() > MAX_STORAGE_KEY_LENGTH {
261                    return HostApiOutcome::Response {
262                        data: encode_response(
263                            &request_id,
264                            request_tag,
265                            &HostResponse::Error("storage key too long".into()),
266                        ),
267                    };
268                }
269                HostApiOutcome::NeedsStorageRead { request_id, key }
270            }
271
272            HostRequest::LocalStorageWrite { key, value } => {
273                if key.len() > MAX_STORAGE_KEY_LENGTH {
274                    return HostApiOutcome::Response {
275                        data: encode_response(
276                            &request_id,
277                            request_tag,
278                            &HostResponse::Error("storage key too long".into()),
279                        ),
280                    };
281                }
282                if value.len() > MAX_STORAGE_VALUE_SIZE {
283                    return HostApiOutcome::Response {
284                        data: encode_response(
285                            &request_id,
286                            request_tag,
287                            &HostResponse::Error("storage value too large".into()),
288                        ),
289                    };
290                }
291                HostApiOutcome::NeedsStorageWrite {
292                    request_id,
293                    key,
294                    value,
295                }
296            }
297
298            HostRequest::LocalStorageClear { key } => {
299                if key.len() > MAX_STORAGE_KEY_LENGTH {
300                    return HostApiOutcome::Response {
301                        data: encode_response(
302                            &request_id,
303                            request_tag,
304                            &HostResponse::Error("storage key too long".into()),
305                        ),
306                    };
307                }
308                HostApiOutcome::NeedsStorageClear { request_id, key }
309            }
310
311            HostRequest::NavigateTo { url } => {
312                log::info!("[hostapi] navigate_to request");
313                HostApiOutcome::NeedsNavigate { request_id, url }
314            }
315
316            HostRequest::PushNotification { text, deeplink } => {
317                log::info!("[hostapi] push_notification request");
318                if text.len() > MAX_PUSH_NOTIFICATION_TEXT_LEN {
319                    return HostApiOutcome::Response {
320                        data: encode_response(
321                            &request_id,
322                            request_tag,
323                            &HostResponse::Error("push notification text too long".into()),
324                        ),
325                    };
326                }
327                if let Some(ref dl) = deeplink {
328                    if dl.len() > MAX_DEEPLINK_URL_LEN {
329                        return HostApiOutcome::Response {
330                            data: encode_response(
331                                &request_id,
332                                request_tag,
333                                &HostResponse::Error("deeplink URL too long".into()),
334                            ),
335                        };
336                    }
337                    if !is_allowed_deeplink_scheme(dl) {
338                        return HostApiOutcome::Response {
339                            data: encode_response(
340                                &request_id,
341                                request_tag,
342                                &HostResponse::Error(
343                                    "deeplink scheme not allowed; only https:// is accepted".into(),
344                                ),
345                            ),
346                        };
347                    }
348                }
349                HostApiOutcome::NeedsPushNotification {
350                    request_id,
351                    text,
352                    deeplink,
353                }
354            }
355
356            HostRequest::SignPayload {
357                public_key,
358                payload,
359            } => {
360                log::info!(
361                    "[hostapi] sign_payload request (pubkey={} bytes)",
362                    public_key.len()
363                );
364                HostApiOutcome::NeedsSign {
365                    request_id,
366                    request_tag,
367                    public_key,
368                    payload,
369                }
370            }
371
372            HostRequest::SignRaw { public_key, data } => {
373                log::info!(
374                    "[hostapi] sign_raw request (pubkey={} bytes)",
375                    public_key.len()
376                );
377                HostApiOutcome::NeedsSign {
378                    request_id,
379                    request_tag,
380                    public_key,
381                    payload: data,
382                }
383            }
384
385            HostRequest::CreateTransaction { .. } => {
386                log::info!("[hostapi] create_transaction (not yet implemented)");
387                HostApiOutcome::Response {
388                    data: encode_response(
389                        &request_id,
390                        request_tag,
391                        &HostResponse::Error("create_transaction not yet implemented".into()),
392                    ),
393                }
394            }
395
396            HostRequest::JsonRpcSend { data } => {
397                // The data is a SCALE string containing a JSON-RPC request body,
398                // or raw bytes we try to interpret as UTF-8 JSON.
399                let json_str = parse_jsonrpc_data(&data);
400                match json_str {
401                    Some((method, params)) => {
402                        log::info!("[hostapi] jsonrpc_send request");
403                        HostApiOutcome::NeedsChainQuery {
404                            request_id,
405                            method,
406                            params,
407                        }
408                    }
409                    None => {
410                        log::warn!("[hostapi] failed to parse JSON-RPC send data");
411                        HostApiOutcome::Response {
412                            data: encode_response(
413                                &request_id,
414                                request_tag,
415                                &HostResponse::Error("invalid json-rpc request".into()),
416                            ),
417                        }
418                    }
419                }
420            }
421
422            HostRequest::JsonRpcSubscribeStart { data } => {
423                let json_str = parse_jsonrpc_data(&data);
424                match json_str {
425                    Some((method, params)) => {
426                        log::info!("[hostapi] jsonrpc_subscribe_start request");
427                        HostApiOutcome::NeedsChainSubscription {
428                            request_id,
429                            method,
430                            params,
431                        }
432                    }
433                    None => {
434                        log::warn!("[hostapi] failed to parse JSON-RPC subscribe data");
435                        HostApiOutcome::Response {
436                            data: encode_response(
437                                &request_id,
438                                request_tag,
439                                &HostResponse::Error("invalid json-rpc subscribe request".into()),
440                            ),
441                        }
442                    }
443                }
444            }
445
446            HostRequest::ChainHeadFollowStart {
447                genesis_hash,
448                with_runtime,
449            } => {
450                log::info!("[hostapi] chainHead follow start (genesis={} bytes, withRuntime={with_runtime})", genesis_hash.len());
451                HostApiOutcome::NeedsChainFollow {
452                    request_id,
453                    genesis_hash,
454                    with_runtime,
455                }
456            }
457
458            HostRequest::ChainHeadRequest {
459                tag,
460                genesis_hash,
461                follow_sub_id,
462                data,
463            } => {
464                let method = match tag {
465                    TAG_CHAIN_HEAD_HEADER_REQ => "chainHead_v1_header",
466                    TAG_CHAIN_HEAD_BODY_REQ => "chainHead_v1_body",
467                    TAG_CHAIN_HEAD_STORAGE_REQ => "chainHead_v1_storage",
468                    TAG_CHAIN_HEAD_CALL_REQ => "chainHead_v1_call",
469                    TAG_CHAIN_HEAD_UNPIN_REQ => "chainHead_v1_unpin",
470                    TAG_CHAIN_HEAD_CONTINUE_REQ => "chainHead_v1_continue",
471                    TAG_CHAIN_HEAD_STOP_OP_REQ => "chainHead_v1_stopOperation",
472                    _ => {
473                        log::warn!("[hostapi] unknown chain head request tag: {tag}");
474                        return HostApiOutcome::Silent;
475                    }
476                };
477                log::info!("[hostapi] chain request: {method} (tag={tag})");
478                HostApiOutcome::NeedsChainRpc {
479                    request_id,
480                    request_tag: tag,
481                    genesis_hash,
482                    json_rpc_method: method.into(),
483                    json_rpc_params: data,
484                    follow_sub_id: Some(follow_sub_id),
485                }
486            }
487
488            HostRequest::ChainSpecRequest { tag, genesis_hash } => {
489                let method = match tag {
490                    TAG_CHAIN_SPEC_GENESIS_REQ => "chainSpec_v1_genesisHash",
491                    TAG_CHAIN_SPEC_NAME_REQ => "chainSpec_v1_chainName",
492                    TAG_CHAIN_SPEC_PROPS_REQ => "chainSpec_v1_properties",
493                    _ => {
494                        log::warn!("[hostapi] unknown chainSpec request tag: {tag}");
495                        return HostApiOutcome::Silent;
496                    }
497                };
498                log::info!("[hostapi] chainSpec request: {method} (tag={tag})");
499                HostApiOutcome::NeedsChainRpc {
500                    request_id,
501                    request_tag: tag,
502                    genesis_hash,
503                    json_rpc_method: method.into(),
504                    json_rpc_params: serde_json::Value::Array(vec![]),
505                    follow_sub_id: None,
506                }
507            }
508
509            HostRequest::ChainTxBroadcast {
510                genesis_hash,
511                transaction,
512            } => {
513                log::info!("[hostapi] transaction broadcast");
514                let tx_hex = chain::bytes_to_hex(&transaction);
515                HostApiOutcome::NeedsChainRpc {
516                    request_id,
517                    request_tag,
518                    genesis_hash,
519                    json_rpc_method: "transaction_v1_broadcast".into(),
520                    json_rpc_params: serde_json::json!([tx_hex]),
521                    follow_sub_id: None,
522                }
523            }
524
525            HostRequest::ChainTxStop {
526                genesis_hash,
527                operation_id,
528            } => {
529                log::info!("[hostapi] transaction stop");
530                HostApiOutcome::NeedsChainRpc {
531                    request_id,
532                    request_tag,
533                    genesis_hash,
534                    json_rpc_method: "transaction_v1_stop".into(),
535                    json_rpc_params: serde_json::json!([operation_id]),
536                    follow_sub_id: None,
537                }
538            }
539
540            HostRequest::Unimplemented { tag } => {
541                log::info!("[hostapi] unimplemented method (tag={tag})");
542                if is_subscription_control(tag) {
543                    HostApiOutcome::Silent
544                } else {
545                    HostApiOutcome::Response {
546                        data: encode_response(
547                            &request_id,
548                            request_tag,
549                            &HostResponse::Error("not implemented".into()),
550                        ),
551                    }
552                }
553            }
554
555            HostRequest::Unknown { tag } => {
556                log::warn!("[hostapi] unknown tag: {tag}");
557                HostApiOutcome::Silent
558            }
559        }
560    }
561}
562
563/// Parse the `data` field from a `JsonRpcSend` request.
564///
565/// The Product SDK encodes this as a SCALE string containing the full JSON-RPC
566/// request (e.g. `{"jsonrpc":"2.0","id":1,"method":"state_getMetadata","params":[]}`).
567/// We try SCALE string first, then fall back to raw UTF-8.
568fn parse_jsonrpc_data(data: &[u8]) -> Option<(String, serde_json::Value)> {
569    // Try SCALE string (compact length + UTF-8 bytes).
570    let json_str = codec::Reader::new(data)
571        .read_string()
572        .ok()
573        .or_else(|| std::str::from_utf8(data).ok().map(|s| s.to_string()))?;
574
575    let v: serde_json::Value = serde_json::from_str(&json_str).ok()?;
576    let method = v.get("method")?.as_str()?.to_string();
577    let params = v
578        .get("params")
579        .cloned()
580        .unwrap_or(serde_json::Value::Array(vec![]));
581    Some((method, params))
582}
583
584/// Check if a tag is a subscription control message (stop/interrupt).
585fn is_subscription_control(tag: u8) -> bool {
586    matches!(
587        tag,
588        TAG_ACCOUNT_STATUS_STOP
589            | TAG_ACCOUNT_STATUS_INTERRUPT
590            | TAG_CHAT_LIST_STOP
591            | TAG_CHAT_LIST_INTERRUPT
592            | TAG_CHAT_ACTION_STOP
593            | TAG_CHAT_ACTION_INTERRUPT
594            | TAG_CHAT_CUSTOM_MSG_STOP
595            | TAG_CHAT_CUSTOM_MSG_INTERRUPT
596            | TAG_STATEMENT_STORE_STOP
597            | TAG_STATEMENT_STORE_INTERRUPT
598            | TAG_PREIMAGE_LOOKUP_STOP
599            | TAG_PREIMAGE_LOOKUP_INTERRUPT
600            | TAG_JSONRPC_SUB_STOP
601            | TAG_JSONRPC_SUB_INTERRUPT
602            | TAG_CHAIN_HEAD_FOLLOW_STOP
603            | TAG_CHAIN_HEAD_FOLLOW_INTERRUPT
604    )
605}
606
607fn decode_error_kind(err: &codec::DecodeErr) -> &'static str {
608    match err {
609        codec::DecodeErr::Eof => "eof",
610        codec::DecodeErr::CompactTooLarge => "compact_too_large",
611        codec::DecodeErr::InvalidUtf8 => "invalid_utf8",
612        codec::DecodeErr::InvalidOption => "invalid_option",
613        codec::DecodeErr::InvalidTag(_) => "invalid_tag",
614        codec::DecodeErr::BadMessage(_) => "bad_message",
615        codec::DecodeErr::UnknownProtocol => "unknown_protocol",
616    }
617}
618
619fn feature_log_kind(feature_data: &[u8]) -> &'static str {
620    match std::str::from_utf8(feature_data) {
621        Ok("signing" | "sign" | "navigate" | "push_notification") => "utf8_known",
622        Ok(_) => "utf8_other",
623        Err(_) if feature_data.first() == Some(&0) => "binary_chain",
624        Err(_) => "binary_other",
625    }
626}
627
628fn request_kind(req: &HostRequest) -> &'static str {
629    match req {
630        HostRequest::Handshake { .. } => "handshake",
631        HostRequest::GetNonProductAccounts => "get_non_product_accounts",
632        HostRequest::FeatureSupported { .. } => "feature_supported",
633        HostRequest::LocalStorageRead { .. } => "local_storage_read",
634        HostRequest::LocalStorageWrite { .. } => "local_storage_write",
635        HostRequest::LocalStorageClear { .. } => "local_storage_clear",
636        HostRequest::SignPayload { .. } => "sign_payload",
637        HostRequest::SignRaw { .. } => "sign_raw",
638        HostRequest::CreateTransaction { .. } => "create_transaction",
639        HostRequest::NavigateTo { .. } => "navigate_to",
640        HostRequest::PushNotification { .. } => "push_notification",
641        HostRequest::AccountConnectionStatusStart => "account_connection_status_start",
642        HostRequest::JsonRpcSend { .. } => "jsonrpc_send",
643        HostRequest::JsonRpcSubscribeStart { .. } => "jsonrpc_subscribe_start",
644        HostRequest::ChainHeadFollowStart { .. } => "chain_head_follow_start",
645        HostRequest::ChainHeadRequest { .. } => "chain_head_request",
646        HostRequest::ChainSpecRequest { .. } => "chain_spec_request",
647        HostRequest::ChainTxBroadcast { .. } => "chain_tx_broadcast",
648        HostRequest::ChainTxStop { .. } => "chain_tx_stop",
649        HostRequest::Unimplemented { .. } => "unimplemented",
650        HostRequest::Unknown { .. } => "unknown",
651    }
652}
653
654/// Check whether a deeplink URI has an allowed scheme.
655/// Only `https://` is accepted by default (case-insensitive on the scheme).
656fn is_allowed_deeplink_scheme(url: &str) -> bool {
657    let lower = url.to_ascii_lowercase();
658    ALLOWED_DEEPLINK_SCHEMES
659        .iter()
660        .any(|scheme| lower.starts_with(scheme))
661}
662
663// ---------------------------------------------------------------------------
664// JS bridge script — injected into WKWebView at document_start
665// ---------------------------------------------------------------------------
666
667/// JavaScript injected before the Polkadot app loads. Sets up:
668/// 1. `window.__HOST_WEBVIEW_MARK__ = true` — SDK webview detection
669/// 2. `MessageChannel` with port2 as `window.__HOST_API_PORT__`
670/// 3. Binary message forwarding between port1 and native (base64)
671pub const HOST_API_BRIDGE_SCRIPT: &str = concat!(
672    r#"
673(function() {
674    'use strict';
675    if (window.__hostApiBridge) { return; }
676    window.__hostApiBridge = true;
677    window.__HOST_WEBVIEW_MARK__ = true;
678    var ch = new MessageChannel();
679    window.__HOST_API_PORT__ = ch.port2;
680    ch.port2.start();
681    var port1 = ch.port1;
682    port1.start();
683    if (!window.host) { window.host = {}; }
684    if (!window.host.storage) { window.host.storage = {}; }
685    port1.onmessage = function(ev) {
686        var data = ev.data;
687        if (!data) { console.warn('[host-bridge] data is falsy, dropping'); return; }
688        var bytes;
689        if (data instanceof Uint8Array) { bytes = data; }
690        else if (data instanceof ArrayBuffer) { bytes = new Uint8Array(data); }
691        else if (ArrayBuffer.isView(data)) { bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); }
692        else { console.warn('[host-bridge] unknown data type: ' + typeof data + ' constructor=' + (data.constructor ? data.constructor.name : '?') + ', dropping'); return; }
693        var binary = '';
694        for (var i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); }
695        try {
696            window.webkit.messageHandlers.hostApi.postMessage(btoa(binary));
697        } catch(e) {
698            console.error('[host-bridge] postMessage to hostApi FAILED:', e.message);
699        }
700    };
701    window.__hostApiReply = function(b64) {
702        try {
703            var binary = atob(b64);
704            var bytes = new Uint8Array(binary.length);
705            for (var i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); }
706            port1.postMessage(bytes);
707        } catch(e) { console.error('[host-bridge] reply failed:', e.message); }
708    };
709"#,
710    include_str!("js/storage_bridge.js"),
711    r#"
712})();
713"#
714);
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719    use crate::protocol::*;
720    use std::sync::atomic::{AtomicBool, Ordering};
721    use std::sync::{Mutex, Once};
722
723    const TEST_APP: &str = "test-app";
724    static TEST_LOGGER_INIT: Once = Once::new();
725    static TEST_LOGGER_INSTALLED: AtomicBool = AtomicBool::new(false);
726    static TEST_LOG_CAPTURE_LOCK: Mutex<()> = Mutex::new(());
727    static TEST_LOGGER: TestLogger = TestLogger::new();
728
729    struct TestLogger {
730        entries: Mutex<Vec<String>>,
731        capture_thread: Mutex<Option<std::thread::ThreadId>>,
732    }
733
734    impl TestLogger {
735        const fn new() -> Self {
736            Self {
737                entries: Mutex::new(Vec::new()),
738                capture_thread: Mutex::new(None),
739            }
740        }
741    }
742
743    impl log::Log for TestLogger {
744        fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
745            metadata.level() <= log::Level::Info
746        }
747
748        fn log(&self, record: &log::Record<'_>) {
749            if !self.enabled(record.metadata()) {
750                return;
751            }
752
753            let capture_thread = self
754                .capture_thread
755                .lock()
756                .unwrap_or_else(|e| e.into_inner());
757            if capture_thread.as_ref() != Some(&std::thread::current().id()) {
758                return;
759            }
760            drop(capture_thread);
761
762            self.entries
763                .lock()
764                .unwrap_or_else(|e| e.into_inner())
765                .push(record.args().to_string());
766        }
767
768        fn flush(&self) {}
769    }
770
771    fn capture_logs<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
772        TEST_LOGGER_INIT.call_once(|| {
773            let installed = log::set_logger(&TEST_LOGGER).is_ok();
774            if installed {
775                log::set_max_level(log::LevelFilter::Info);
776            }
777            TEST_LOGGER_INSTALLED.store(installed, Ordering::Relaxed);
778        });
779        assert!(
780            TEST_LOGGER_INSTALLED.load(Ordering::Relaxed),
781            "test logger could not be installed"
782        );
783
784        let _guard = TEST_LOG_CAPTURE_LOCK
785            .lock()
786            .unwrap_or_else(|e| e.into_inner());
787
788        TEST_LOGGER
789            .entries
790            .lock()
791            .unwrap_or_else(|e| e.into_inner())
792            .clear();
793        *TEST_LOGGER
794            .capture_thread
795            .lock()
796            .unwrap_or_else(|e| e.into_inner()) = Some(std::thread::current().id());
797
798        let result = f();
799
800        *TEST_LOGGER
801            .capture_thread
802            .lock()
803            .unwrap_or_else(|e| e.into_inner()) = None;
804        let logs = TEST_LOGGER
805            .entries
806            .lock()
807            .unwrap_or_else(|e| e.into_inner())
808            .clone();
809        TEST_LOGGER
810            .entries
811            .lock()
812            .unwrap_or_else(|e| e.into_inner())
813            .clear();
814
815        (result, logs)
816    }
817
818    fn assert_logs_contain(logs: &[String], needle: &str) {
819        let joined = logs.join("\n");
820        assert!(
821            joined.contains(needle),
822            "expected logs to contain {needle:?}, got:\n{joined}"
823        );
824    }
825
826    fn assert_logs_do_not_contain(logs: &[String], needle: &str) {
827        let joined = logs.join("\n");
828        assert!(
829            !joined.contains(needle),
830            "expected logs not to contain {needle:?}, got:\n{joined}"
831        );
832    }
833
834    #[test]
835    fn host_api_bridge_script_exposes_storage_surface() {
836        assert!(HOST_API_BRIDGE_SCRIPT.contains("window.host.storage.get"));
837        assert!(HOST_API_BRIDGE_SCRIPT.contains("window.host.storage.set"));
838        assert!(HOST_API_BRIDGE_SCRIPT.contains("window.host.storage.remove"));
839    }
840
841    /// Extract a Response from HostApiOutcome, panicking on other variants.
842    fn expect_response(outcome: HostApiOutcome) -> Vec<u8> {
843        match outcome {
844            HostApiOutcome::Response { data: v } => v,
845            other => panic!("expected Response, got {}", outcome_name(&other)),
846        }
847    }
848
849    fn expect_silent(outcome: HostApiOutcome) {
850        match outcome {
851            HostApiOutcome::Silent => {}
852            other => panic!("expected Silent, got {}", outcome_name(&other)),
853        }
854    }
855
856    fn outcome_name(o: &HostApiOutcome) -> &'static str {
857        match o {
858            HostApiOutcome::Response { .. } => "Response",
859            HostApiOutcome::NeedsSign { .. } => "NeedsSign",
860            HostApiOutcome::NeedsChainQuery { .. } => "NeedsChainQuery",
861            HostApiOutcome::NeedsChainSubscription { .. } => "NeedsChainSubscription",
862            HostApiOutcome::NeedsNavigate { .. } => "NeedsNavigate",
863            HostApiOutcome::NeedsPushNotification { .. } => "NeedsPushNotification",
864            HostApiOutcome::NeedsChainFollow { .. } => "NeedsChainFollow",
865            HostApiOutcome::NeedsChainRpc { .. } => "NeedsChainRpc",
866            HostApiOutcome::NeedsStorageRead { .. } => "NeedsStorageRead",
867            HostApiOutcome::NeedsStorageWrite { .. } => "NeedsStorageWrite",
868            HostApiOutcome::NeedsStorageClear { .. } => "NeedsStorageClear",
869            HostApiOutcome::Silent => "Silent",
870        }
871    }
872
873    fn make_handshake_request(request_id: &str) -> Vec<u8> {
874        let mut msg = Vec::new();
875        msg.push(PROTOCOL_DISCRIMINATOR);
876        codec::encode_string(&mut msg, request_id);
877        msg.push(TAG_HANDSHAKE_REQ);
878        msg.push(0); // v1
879        msg.push(PROTOCOL_VERSION);
880        msg
881    }
882
883    fn make_get_accounts_request(request_id: &str) -> Vec<u8> {
884        let mut msg = Vec::new();
885        msg.push(PROTOCOL_DISCRIMINATOR);
886        codec::encode_string(&mut msg, request_id);
887        msg.push(TAG_GET_NON_PRODUCT_ACCOUNTS_REQ);
888        msg.push(0); // v1
889        msg
890    }
891
892    fn make_storage_write(request_id: &str, key: &str, value: &[u8]) -> Vec<u8> {
893        let mut msg = Vec::new();
894        msg.push(PROTOCOL_DISCRIMINATOR);
895        codec::encode_string(&mut msg, request_id);
896        msg.push(TAG_LOCAL_STORAGE_WRITE_REQ);
897        msg.push(0); // v1
898        codec::encode_string(&mut msg, key);
899        codec::encode_var_bytes(&mut msg, value);
900        msg
901    }
902
903    fn make_storage_read(request_id: &str, key: &str) -> Vec<u8> {
904        let mut msg = Vec::new();
905        msg.push(PROTOCOL_DISCRIMINATOR);
906        codec::encode_string(&mut msg, request_id);
907        msg.push(TAG_LOCAL_STORAGE_READ_REQ);
908        msg.push(0); // v1
909        codec::encode_string(&mut msg, key);
910        msg
911    }
912
913    fn make_storage_clear(request_id: &str, key: &str) -> Vec<u8> {
914        let mut msg = Vec::new();
915        msg.push(PROTOCOL_DISCRIMINATOR);
916        codec::encode_string(&mut msg, request_id);
917        msg.push(TAG_LOCAL_STORAGE_CLEAR_REQ);
918        msg.push(0); // v1
919        codec::encode_string(&mut msg, key);
920        msg
921    }
922
923    fn make_feature_supported_request(request_id: &str, feature_data: &[u8]) -> Vec<u8> {
924        let mut msg = Vec::new();
925        msg.push(PROTOCOL_DISCRIMINATOR);
926        codec::encode_string(&mut msg, request_id);
927        msg.push(TAG_FEATURE_SUPPORTED_REQ);
928        msg.push(0); // v1
929        msg.extend_from_slice(feature_data);
930        msg
931    }
932
933    fn make_navigate_request(request_id: &str, url: &str) -> Vec<u8> {
934        let mut msg = Vec::new();
935        msg.push(PROTOCOL_DISCRIMINATOR);
936        codec::encode_string(&mut msg, request_id);
937        msg.push(TAG_NAVIGATE_TO_REQ);
938        msg.push(0); // v1
939        codec::encode_string(&mut msg, url);
940        msg
941    }
942
943    fn make_jsonrpc_request(request_id: &str, tag: u8, json: &str) -> Vec<u8> {
944        let mut msg = Vec::new();
945        msg.push(PROTOCOL_DISCRIMINATOR);
946        codec::encode_string(&mut msg, request_id);
947        msg.push(tag);
948        msg.push(0); // v1
949        codec::encode_string(&mut msg, json);
950        msg
951    }
952
953    #[test]
954    fn handshake_flow() {
955        let mut api = HostApi::new();
956        let req = make_handshake_request("hs-1");
957        let resp = expect_response(api.handle_message(&req, TEST_APP));
958
959        let mut r = codec::Reader::new(&resp);
960        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
961        assert_eq!(r.read_string().unwrap(), "hs-1");
962        assert_eq!(r.read_u8().unwrap(), TAG_HANDSHAKE_RESP);
963        assert_eq!(r.read_u8().unwrap(), 0); // v1
964        assert_eq!(r.read_u8().unwrap(), 0); // Result::Ok
965    }
966
967    #[test]
968    fn handshake_wrong_version() {
969        let mut api = HostApi::new();
970        let mut req = Vec::new();
971        req.push(PROTOCOL_DISCRIMINATOR);
972        codec::encode_string(&mut req, "hs-bad");
973        req.push(TAG_HANDSHAKE_REQ);
974        req.push(0); // v1
975        req.push(255); // wrong version
976        let resp = expect_response(api.handle_message(&req, TEST_APP));
977
978        let mut r = codec::Reader::new(&resp);
979        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
980        assert_eq!(r.read_string().unwrap(), "hs-bad");
981        assert_eq!(r.read_u8().unwrap(), TAG_HANDSHAKE_RESP);
982        assert_eq!(r.read_u8().unwrap(), 0); // v1
983        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
984    }
985
986    #[test]
987    fn request_kind_redacts_payload_variants() {
988        assert_eq!(
989            request_kind(&HostRequest::SignPayload {
990                public_key: vec![0xAA; 32],
991                payload: b"secret-payload".to_vec(),
992            }),
993            "sign_payload"
994        );
995        assert_eq!(
996            request_kind(&HostRequest::NavigateTo {
997                url: "https://example.com/private".into(),
998            }),
999            "navigate_to"
1000        );
1001        assert_eq!(
1002            request_kind(&HostRequest::LocalStorageWrite {
1003                key: "secret".into(),
1004                value: b"top-secret".to_vec(),
1005            }),
1006            "local_storage_write"
1007        );
1008    }
1009
1010    #[test]
1011    fn decode_error_kind_redacts_error_details() {
1012        assert_eq!(
1013            decode_error_kind(&codec::DecodeErr::BadMessage("secret")),
1014            "bad_message"
1015        );
1016        assert_eq!(
1017            decode_error_kind(&codec::DecodeErr::InvalidTag(255)),
1018            "invalid_tag"
1019        );
1020        assert_eq!(
1021            decode_error_kind(&codec::DecodeErr::UnknownProtocol),
1022            "unknown_protocol"
1023        );
1024    }
1025
1026    #[test]
1027    fn feature_log_kind_redacts_feature_data() {
1028        assert_eq!(feature_log_kind(b"signing"), "utf8_known");
1029        assert_eq!(feature_log_kind(b"secret-feature"), "utf8_other");
1030        assert_eq!(
1031            feature_log_kind(&[0, 0xde, 0xad, 0xbe, 0xef]),
1032            "binary_chain"
1033        );
1034        assert_eq!(
1035            feature_log_kind(&[1, 0xde, 0xad, 0xbe, 0xef]),
1036            "binary_other"
1037        );
1038    }
1039
1040    #[test]
1041    fn get_accounts_empty() {
1042        let mut api = HostApi::new();
1043        let req = make_get_accounts_request("acc-1");
1044        let resp = expect_response(api.handle_message(&req, TEST_APP));
1045
1046        let mut r = codec::Reader::new(&resp);
1047        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1048        assert_eq!(r.read_string().unwrap(), "acc-1");
1049        assert_eq!(r.read_u8().unwrap(), TAG_GET_NON_PRODUCT_ACCOUNTS_RESP);
1050        assert_eq!(r.read_u8().unwrap(), 0); // v1
1051        assert_eq!(r.read_u8().unwrap(), 0); // Result::Ok
1052        assert_eq!(r.read_compact_u32().unwrap(), 0); // empty vector
1053    }
1054
1055    #[test]
1056    fn get_accounts_returns_configured_accounts() {
1057        let mut api = HostApi::new();
1058        api.set_accounts(vec![Account {
1059            public_key: vec![0xAA; 32],
1060            name: Some("Test Account".into()),
1061        }]);
1062
1063        let req = make_get_accounts_request("acc-2");
1064        let resp = expect_response(api.handle_message(&req, TEST_APP));
1065
1066        let mut r = codec::Reader::new(&resp);
1067        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1068        assert_eq!(r.read_string().unwrap(), "acc-2");
1069        assert_eq!(r.read_u8().unwrap(), TAG_GET_NON_PRODUCT_ACCOUNTS_RESP);
1070        assert_eq!(r.read_u8().unwrap(), 0); // v1
1071        assert_eq!(r.read_u8().unwrap(), 0); // Result::Ok
1072        assert_eq!(r.read_compact_u32().unwrap(), 1); // 1 account
1073    }
1074
1075    #[test]
1076    fn storage_read_returns_needs_storage_read() {
1077        let mut api = HostApi::new();
1078        let req = make_storage_read("r-1", "mykey");
1079
1080        match api.handle_message(&req, TEST_APP) {
1081            HostApiOutcome::NeedsStorageRead { request_id, key } => {
1082                assert_eq!(request_id, "r-1");
1083                assert_eq!(key, "mykey");
1084            }
1085            other => panic!("expected NeedsStorageRead, got {}", outcome_name(&other)),
1086        }
1087    }
1088
1089    #[test]
1090    fn storage_write_returns_needs_storage_write() {
1091        let mut api = HostApi::new();
1092        let req = make_storage_write("w-1", "mykey", b"myvalue");
1093
1094        match api.handle_message(&req, TEST_APP) {
1095            HostApiOutcome::NeedsStorageWrite {
1096                request_id,
1097                key,
1098                value,
1099            } => {
1100                assert_eq!(request_id, "w-1");
1101                assert_eq!(key, "mykey");
1102                assert_eq!(value, b"myvalue");
1103            }
1104            other => panic!("expected NeedsStorageWrite, got {}", outcome_name(&other)),
1105        }
1106    }
1107
1108    #[test]
1109    fn storage_clear_returns_needs_storage_clear() {
1110        let mut api = HostApi::new();
1111        let req = make_storage_clear("c-1", "clearme");
1112
1113        match api.handle_message(&req, TEST_APP) {
1114            HostApiOutcome::NeedsStorageClear { request_id, key } => {
1115                assert_eq!(request_id, "c-1");
1116                assert_eq!(key, "clearme");
1117            }
1118            other => panic!("expected NeedsStorageClear, got {}", outcome_name(&other)),
1119        }
1120    }
1121
1122    #[test]
1123    fn storage_clear_of_nonexistent_key_emits_outcome() {
1124        let mut api = HostApi::new();
1125        let req = make_storage_clear("c-2", "never-written");
1126
1127        match api.handle_message(&req, TEST_APP) {
1128            HostApiOutcome::NeedsStorageClear { request_id, key } => {
1129                assert_eq!(request_id, "c-2");
1130                assert_eq!(key, "never-written");
1131            }
1132            other => panic!("expected NeedsStorageClear, got {}", outcome_name(&other)),
1133        }
1134    }
1135
1136    #[test]
1137    fn device_permission_returns_unimplemented_error() {
1138        let mut api = HostApi::new();
1139        let mut msg = Vec::new();
1140        msg.push(PROTOCOL_DISCRIMINATOR);
1141        codec::encode_string(&mut msg, "unimp-1");
1142        msg.push(TAG_DEVICE_PERMISSION_REQ);
1143
1144        let resp = expect_response(api.handle_message(&msg, TEST_APP));
1145
1146        let mut r = codec::Reader::new(&resp);
1147        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1148        assert_eq!(r.read_string().unwrap(), "unimp-1");
1149        assert_eq!(r.read_u8().unwrap(), TAG_DEVICE_PERMISSION_RESP);
1150        assert_eq!(r.read_u8().unwrap(), 0); // v1
1151        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1152    }
1153
1154    #[test]
1155    fn subscription_stop_returns_silent() {
1156        let mut api = HostApi::new();
1157        let mut msg = Vec::new();
1158        msg.push(PROTOCOL_DISCRIMINATOR);
1159        codec::encode_string(&mut msg, "stop-1");
1160        msg.push(TAG_ACCOUNT_STATUS_STOP);
1161
1162        expect_silent(api.handle_message(&msg, TEST_APP));
1163    }
1164
1165    #[test]
1166    fn malformed_input_returns_silent() {
1167        let mut api = HostApi::new();
1168
1169        // Empty input
1170        expect_silent(api.handle_message(&[], TEST_APP));
1171
1172        // Truncated after discriminator + request_id (no tag)
1173        let mut msg = Vec::new();
1174        msg.push(PROTOCOL_DISCRIMINATOR);
1175        codec::encode_string(&mut msg, "trunc");
1176        expect_silent(api.handle_message(&msg, TEST_APP));
1177    }
1178
1179    #[test]
1180    fn unknown_tag_returns_silent() {
1181        let mut api = HostApi::new();
1182        let mut msg = Vec::new();
1183        msg.push(PROTOCOL_DISCRIMINATOR);
1184        codec::encode_string(&mut msg, "unk-1");
1185        msg.push(0xFF); // unknown tag
1186        expect_silent(api.handle_message(&msg, TEST_APP));
1187    }
1188
1189    #[test]
1190    fn storage_write_rejects_oversized_value() {
1191        let mut api = HostApi::new();
1192        let big_value = vec![0xAA; super::MAX_STORAGE_VALUE_SIZE + 1];
1193        let req = make_storage_write("w-big", "key", &big_value);
1194        let resp = expect_response(api.handle_message(&req, TEST_APP));
1195
1196        let mut r = codec::Reader::new(&resp);
1197        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1198        assert_eq!(r.read_string().unwrap(), "w-big");
1199        assert_eq!(r.read_u8().unwrap(), TAG_LOCAL_STORAGE_WRITE_RESP);
1200        assert_eq!(r.read_u8().unwrap(), 0); // v1
1201        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1202    }
1203
1204    #[test]
1205    fn storage_write_rejects_long_key() {
1206        let mut api = HostApi::new();
1207        let long_key = "k".repeat(super::MAX_STORAGE_KEY_LENGTH + 1);
1208        let req = make_storage_write("w-longkey", &long_key, b"v");
1209        let resp = expect_response(api.handle_message(&req, TEST_APP));
1210
1211        let mut r = codec::Reader::new(&resp);
1212        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1213        assert_eq!(r.read_string().unwrap(), "w-longkey");
1214        assert_eq!(r.read_u8().unwrap(), TAG_LOCAL_STORAGE_WRITE_RESP);
1215        assert_eq!(r.read_u8().unwrap(), 0); // v1
1216        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1217    }
1218
1219    #[test]
1220    fn sign_payload_returns_needs_sign() {
1221        let mut api = HostApi::new();
1222        let mut msg = Vec::new();
1223        msg.push(PROTOCOL_DISCRIMINATOR);
1224        codec::encode_string(&mut msg, "sign-1");
1225        msg.push(TAG_SIGN_PAYLOAD_REQ);
1226        msg.push(0); // v1
1227        codec::encode_var_bytes(&mut msg, &[0xAA; 32]); // publicKey
1228        msg.extend_from_slice(b"payload-data");
1229
1230        match api.handle_message(&msg, TEST_APP) {
1231            HostApiOutcome::NeedsSign {
1232                request_id,
1233                request_tag,
1234                public_key,
1235                payload,
1236            } => {
1237                assert_eq!(request_id, "sign-1");
1238                assert_eq!(request_tag, TAG_SIGN_PAYLOAD_REQ);
1239                assert_eq!(public_key, vec![0xAA; 32]);
1240                assert_eq!(payload, b"payload-data");
1241            }
1242            _ => panic!("expected NeedsSign"),
1243        }
1244    }
1245
1246    #[test]
1247    fn sign_raw_returns_needs_sign() {
1248        let mut api = HostApi::new();
1249        let mut msg = Vec::new();
1250        msg.push(PROTOCOL_DISCRIMINATOR);
1251        codec::encode_string(&mut msg, "sign-2");
1252        msg.push(TAG_SIGN_RAW_REQ);
1253        msg.push(0); // v1
1254        codec::encode_var_bytes(&mut msg, &[0xBB; 32]); // publicKey
1255        msg.extend_from_slice(b"raw-bytes");
1256
1257        match api.handle_message(&msg, TEST_APP) {
1258            HostApiOutcome::NeedsSign {
1259                request_id,
1260                request_tag,
1261                public_key,
1262                payload,
1263            } => {
1264                assert_eq!(request_id, "sign-2");
1265                assert_eq!(request_tag, TAG_SIGN_RAW_REQ);
1266                assert_eq!(public_key, vec![0xBB; 32]);
1267                assert_eq!(payload, b"raw-bytes");
1268            }
1269            _ => panic!("expected NeedsSign"),
1270        }
1271    }
1272
1273    #[test]
1274    fn logging_redacts_sign_payload_requests() {
1275        let mut api = HostApi::new();
1276        let request_id = "req-id-secret-123";
1277        let payload_secret = "payload-secret-456";
1278        let pubkey_secret_hex = "abababab";
1279        let mut msg = Vec::new();
1280        msg.push(PROTOCOL_DISCRIMINATOR);
1281        codec::encode_string(&mut msg, request_id);
1282        msg.push(TAG_SIGN_PAYLOAD_REQ);
1283        msg.push(0); // v1
1284        codec::encode_var_bytes(&mut msg, &[0xAB; 32]);
1285        msg.extend_from_slice(payload_secret.as_bytes());
1286
1287        let (outcome, logs) = capture_logs(|| api.handle_message(&msg, TEST_APP));
1288
1289        match outcome {
1290            HostApiOutcome::NeedsSign {
1291                request_id: actual_request_id,
1292                request_tag,
1293                public_key,
1294                payload,
1295            } => {
1296                assert_eq!(actual_request_id, request_id);
1297                assert_eq!(request_tag, TAG_SIGN_PAYLOAD_REQ);
1298                assert_eq!(public_key, vec![0xAB; 32]);
1299                assert_eq!(payload, payload_secret.as_bytes());
1300            }
1301            other => panic!("expected NeedsSign, got {}", outcome_name(&other)),
1302        }
1303
1304        assert_logs_contain(&logs, "request: kind=sign_payload (tag=36)");
1305        assert_logs_contain(&logs, "sign_payload request (pubkey=32 bytes)");
1306        assert_logs_do_not_contain(&logs, request_id);
1307        assert_logs_do_not_contain(&logs, payload_secret);
1308        assert_logs_do_not_contain(&logs, pubkey_secret_hex);
1309    }
1310
1311    #[test]
1312    fn logging_redacts_sign_raw_requests() {
1313        let mut api = HostApi::new();
1314        let request_id = "req-id-secret-raw";
1315        let raw_secret = "raw-secret-789";
1316        let mut msg = Vec::new();
1317        msg.push(PROTOCOL_DISCRIMINATOR);
1318        codec::encode_string(&mut msg, request_id);
1319        msg.push(TAG_SIGN_RAW_REQ);
1320        msg.push(0); // v1
1321        codec::encode_var_bytes(&mut msg, &[0xCD; 32]);
1322        msg.extend_from_slice(raw_secret.as_bytes());
1323
1324        let (outcome, logs) = capture_logs(|| api.handle_message(&msg, TEST_APP));
1325
1326        match outcome {
1327            HostApiOutcome::NeedsSign {
1328                request_id: actual_request_id,
1329                request_tag,
1330                public_key,
1331                payload,
1332            } => {
1333                assert_eq!(actual_request_id, request_id);
1334                assert_eq!(request_tag, TAG_SIGN_RAW_REQ);
1335                assert_eq!(public_key, vec![0xCD; 32]);
1336                assert_eq!(payload, raw_secret.as_bytes());
1337            }
1338            other => panic!("expected NeedsSign, got {}", outcome_name(&other)),
1339        }
1340
1341        assert_logs_contain(&logs, "request: kind=sign_raw (tag=34)");
1342        assert_logs_contain(&logs, "sign_raw request (pubkey=32 bytes)");
1343        assert_logs_do_not_contain(&logs, request_id);
1344        assert_logs_do_not_contain(&logs, raw_secret);
1345    }
1346
1347    #[test]
1348    fn logging_redacts_navigation_requests() {
1349        let mut api = HostApi::new();
1350        let request_id = "req-id-secret-nav";
1351        let url = "https://example.com/callback?token=nav-secret-123";
1352        let req = make_navigate_request(request_id, url);
1353
1354        let (outcome, logs) = capture_logs(|| api.handle_message(&req, TEST_APP));
1355
1356        match outcome {
1357            HostApiOutcome::NeedsNavigate {
1358                request_id: actual_request_id,
1359                url: actual_url,
1360            } => {
1361                assert_eq!(actual_request_id, request_id);
1362                assert_eq!(actual_url, url);
1363            }
1364            other => panic!("expected NeedsNavigate, got {}", outcome_name(&other)),
1365        }
1366
1367        assert_logs_contain(&logs, "request: kind=navigate_to (tag=6)");
1368        assert_logs_contain(&logs, "navigate_to request");
1369        assert_logs_do_not_contain(&logs, request_id);
1370        assert_logs_do_not_contain(&logs, "nav-secret-123");
1371    }
1372
1373    #[test]
1374    fn logging_redacts_local_storage_write_requests() {
1375        let mut api = HostApi::new();
1376        let request_id = "req-id-secret-storage";
1377        let key = "storage-secret-key";
1378        let value = b"storage-secret-value";
1379        let req = make_storage_write(request_id, key, value);
1380
1381        let (outcome, logs) = capture_logs(|| api.handle_message(&req, TEST_APP));
1382        match outcome {
1383            HostApiOutcome::NeedsStorageWrite {
1384                request_id: actual_request_id,
1385                key: actual_key,
1386                value: actual_value,
1387            } => {
1388                assert_eq!(actual_request_id, request_id);
1389                assert_eq!(actual_key, key);
1390                assert_eq!(actual_value, value);
1391            }
1392            other => panic!("expected NeedsStorageWrite, got {}", outcome_name(&other)),
1393        }
1394
1395        assert_logs_contain(&logs, "request: kind=local_storage_write (tag=14)");
1396        assert_logs_do_not_contain(&logs, request_id);
1397        assert_logs_do_not_contain(&logs, key);
1398        assert_logs_do_not_contain(&logs, "storage-secret-value");
1399    }
1400
1401    #[test]
1402    fn logging_redacts_feature_supported_requests() {
1403        let mut api = HostApi::new();
1404        let utf8_secret = "feature-secret-utf8";
1405        let utf8_req =
1406            make_feature_supported_request("req-id-feature-utf8", utf8_secret.as_bytes());
1407
1408        let (utf8_outcome, utf8_logs) = capture_logs(|| api.handle_message(&utf8_req, TEST_APP));
1409        let utf8_resp = expect_response(utf8_outcome);
1410        let mut utf8_reader = codec::Reader::new(&utf8_resp);
1411        utf8_reader.read_u8().unwrap(); // discriminator
1412        utf8_reader.read_string().unwrap();
1413        assert_eq!(utf8_reader.read_u8().unwrap(), TAG_FEATURE_SUPPORTED_RESP);
1414        utf8_reader.read_u8().unwrap();
1415        utf8_reader.read_u8().unwrap();
1416        assert_eq!(utf8_reader.read_u8().unwrap(), 0);
1417        assert_logs_contain(&utf8_logs, "request: kind=feature_supported (tag=2)");
1418        assert_logs_contain(&utf8_logs, "feature_supported: feature=utf8_other -> false");
1419        assert_logs_do_not_contain(&utf8_logs, utf8_secret);
1420
1421        let binary_req =
1422            make_feature_supported_request("req-id-feature-binary", &[0, 0xde, 0xad, 0xbe, 0xef]);
1423        let (binary_outcome, binary_logs) =
1424            capture_logs(|| api.handle_message(&binary_req, TEST_APP));
1425        let binary_resp = expect_response(binary_outcome);
1426        let mut binary_reader = codec::Reader::new(&binary_resp);
1427        binary_reader.read_u8().unwrap(); // discriminator
1428        binary_reader.read_string().unwrap();
1429        assert_eq!(binary_reader.read_u8().unwrap(), TAG_FEATURE_SUPPORTED_RESP);
1430        binary_reader.read_u8().unwrap();
1431        binary_reader.read_u8().unwrap();
1432        assert_eq!(binary_reader.read_u8().unwrap(), 0);
1433        assert_logs_contain(
1434            &binary_logs,
1435            "feature_supported: feature=binary_chain -> false",
1436        );
1437        assert_logs_do_not_contain(&binary_logs, "deadbeef");
1438    }
1439
1440    #[test]
1441    fn logging_redacts_jsonrpc_requests() {
1442        let mut api = HostApi::new();
1443        let request_id = "req-id-secret-jsonrpc";
1444        let json = r#"{"jsonrpc":"2.0","id":1,"method":"rpc-secret-method","params":["jsonrpc-secret-param"]}"#;
1445        let req = make_jsonrpc_request(request_id, TAG_JSONRPC_SEND_REQ, json);
1446
1447        let (outcome, logs) = capture_logs(|| api.handle_message(&req, TEST_APP));
1448
1449        match outcome {
1450            HostApiOutcome::NeedsChainQuery {
1451                request_id: actual_request_id,
1452                method,
1453                params,
1454            } => {
1455                assert_eq!(actual_request_id, request_id);
1456                assert_eq!(method, "rpc-secret-method");
1457                assert_eq!(params, serde_json::json!(["jsonrpc-secret-param"]));
1458            }
1459            other => panic!("expected NeedsChainQuery, got {}", outcome_name(&other)),
1460        }
1461
1462        assert_logs_contain(&logs, "request: kind=jsonrpc_send (tag=70)");
1463        assert_logs_contain(&logs, "jsonrpc_send request");
1464        assert_logs_do_not_contain(&logs, request_id);
1465        assert_logs_do_not_contain(&logs, "rpc-secret-method");
1466        assert_logs_do_not_contain(&logs, "jsonrpc-secret-param");
1467    }
1468
1469    #[test]
1470    fn logging_redacts_jsonrpc_subscribe_requests() {
1471        let mut api = HostApi::new();
1472        let request_id = "req-id-secret-jsonrpc-sub";
1473        let json = r#"{"jsonrpc":"2.0","id":1,"method":"rpc-secret-subscribe","params":["jsonrpc-secret-sub-param"]}"#;
1474        let req = make_jsonrpc_request(request_id, TAG_JSONRPC_SUB_START, json);
1475
1476        let (outcome, logs) = capture_logs(|| api.handle_message(&req, TEST_APP));
1477
1478        match outcome {
1479            HostApiOutcome::NeedsChainSubscription {
1480                request_id: actual_request_id,
1481                method,
1482                params,
1483            } => {
1484                assert_eq!(actual_request_id, request_id);
1485                assert_eq!(method, "rpc-secret-subscribe");
1486                assert_eq!(params, serde_json::json!(["jsonrpc-secret-sub-param"]));
1487            }
1488            other => panic!(
1489                "expected NeedsChainSubscription, got {}",
1490                outcome_name(&other)
1491            ),
1492        }
1493
1494        assert_logs_contain(&logs, "request: kind=jsonrpc_subscribe_start (tag=72)");
1495        assert_logs_contain(&logs, "jsonrpc_subscribe_start request");
1496        assert_logs_do_not_contain(&logs, request_id);
1497        assert_logs_do_not_contain(&logs, "rpc-secret-subscribe");
1498        assert_logs_do_not_contain(&logs, "jsonrpc-secret-sub-param");
1499    }
1500
1501    #[test]
1502    fn logging_redacts_decode_failures() {
1503        let mut api = HostApi::new();
1504        let malformed = b"decode-secret-123";
1505
1506        let (outcome, logs) = capture_logs(|| api.handle_message(malformed, TEST_APP));
1507
1508        expect_silent(outcome);
1509        assert_logs_contain(&logs, "failed to decode message: kind=");
1510        assert_logs_do_not_contain(&logs, "decode-secret-123");
1511    }
1512
1513    // -- Push notification tests --
1514
1515    fn make_push_notification_request(
1516        request_id: &str,
1517        text: &str,
1518        deeplink: Option<&str>,
1519    ) -> Vec<u8> {
1520        let mut msg = Vec::new();
1521        msg.push(PROTOCOL_DISCRIMINATOR);
1522        codec::encode_string(&mut msg, request_id);
1523        msg.push(TAG_PUSH_NOTIFICATION_REQ);
1524        msg.push(0); // v1
1525        codec::encode_string(&mut msg, text);
1526        match deeplink {
1527            Some(dl) => {
1528                codec::encode_option_some(&mut msg);
1529                codec::encode_string(&mut msg, dl);
1530            }
1531            None => codec::encode_option_none(&mut msg),
1532        }
1533        msg
1534    }
1535
1536    #[test]
1537    fn push_notification_returns_needs_push_notification() {
1538        let mut api = HostApi::new();
1539        let req = make_push_notification_request(
1540            "pn-1",
1541            "Transfer complete",
1542            Some("https://app.example/tx/123"),
1543        );
1544
1545        match api.handle_message(&req, TEST_APP) {
1546            HostApiOutcome::NeedsPushNotification {
1547                request_id,
1548                text,
1549                deeplink,
1550            } => {
1551                assert_eq!(request_id, "pn-1");
1552                assert_eq!(text, "Transfer complete");
1553                assert_eq!(deeplink.as_deref(), Some("https://app.example/tx/123"));
1554            }
1555            other => panic!(
1556                "expected NeedsPushNotification, got {}",
1557                outcome_name(&other)
1558            ),
1559        }
1560    }
1561
1562    #[test]
1563    fn push_notification_without_deeplink() {
1564        let mut api = HostApi::new();
1565        let req = make_push_notification_request("pn-2", "Hello world", None);
1566
1567        match api.handle_message(&req, TEST_APP) {
1568            HostApiOutcome::NeedsPushNotification {
1569                request_id,
1570                text,
1571                deeplink,
1572            } => {
1573                assert_eq!(request_id, "pn-2");
1574                assert_eq!(text, "Hello world");
1575                assert!(deeplink.is_none());
1576            }
1577            other => panic!(
1578                "expected NeedsPushNotification, got {}",
1579                outcome_name(&other)
1580            ),
1581        }
1582    }
1583
1584    #[test]
1585    fn push_notification_rejects_oversized_text() {
1586        let mut api = HostApi::new();
1587        let big_text = "x".repeat(super::MAX_PUSH_NOTIFICATION_TEXT_LEN + 1);
1588        let req = make_push_notification_request("pn-big", &big_text, None);
1589        let resp = expect_response(api.handle_message(&req, TEST_APP));
1590
1591        let mut r = codec::Reader::new(&resp);
1592        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1593        assert_eq!(r.read_string().unwrap(), "pn-big");
1594        assert_eq!(r.read_u8().unwrap(), TAG_PUSH_NOTIFICATION_RESP);
1595        assert_eq!(r.read_u8().unwrap(), 0); // v1
1596        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1597        assert_eq!(r.read_u8().unwrap(), 0); // error variant 0
1598        assert_eq!(r.pos, resp.len(), "no unconsumed bytes expected");
1599    }
1600
1601    #[test]
1602    fn push_notification_accepts_max_length_text() {
1603        let mut api = HostApi::new();
1604        let text_at_limit = "x".repeat(super::MAX_PUSH_NOTIFICATION_TEXT_LEN);
1605        let req = make_push_notification_request("pn-limit", &text_at_limit, None);
1606
1607        match api.handle_message(&req, TEST_APP) {
1608            HostApiOutcome::NeedsPushNotification {
1609                request_id, text, ..
1610            } => {
1611                assert_eq!(request_id, "pn-limit");
1612                assert_eq!(text.len(), super::MAX_PUSH_NOTIFICATION_TEXT_LEN);
1613            }
1614            other => panic!(
1615                "expected NeedsPushNotification for text at limit, got {}",
1616                outcome_name(&other)
1617            ),
1618        }
1619    }
1620
1621    #[test]
1622    fn push_notification_rejects_javascript_deeplink() {
1623        let mut api = HostApi::new();
1624        let req = make_push_notification_request("pn-js", "hi", Some("javascript:alert(1)"));
1625        let resp = expect_response(api.handle_message(&req, TEST_APP));
1626
1627        let mut r = codec::Reader::new(&resp);
1628        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1629        assert_eq!(r.read_string().unwrap(), "pn-js");
1630        assert_eq!(r.read_u8().unwrap(), TAG_PUSH_NOTIFICATION_RESP);
1631        assert_eq!(r.read_u8().unwrap(), 0); // v1
1632        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1633        assert_eq!(r.read_u8().unwrap(), 0); // error variant 0
1634        assert_eq!(r.pos, resp.len(), "no unconsumed bytes expected");
1635    }
1636
1637    #[test]
1638    fn push_notification_rejects_file_deeplink() {
1639        let mut api = HostApi::new();
1640        let req = make_push_notification_request("pn-file", "hi", Some("file:///etc/passwd"));
1641        let resp = expect_response(api.handle_message(&req, TEST_APP));
1642
1643        let mut r = codec::Reader::new(&resp);
1644        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1645        assert_eq!(r.read_string().unwrap(), "pn-file");
1646        assert_eq!(r.read_u8().unwrap(), TAG_PUSH_NOTIFICATION_RESP);
1647        assert_eq!(r.read_u8().unwrap(), 0); // v1
1648        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1649        assert_eq!(r.read_u8().unwrap(), 0); // error variant 0
1650        assert_eq!(r.pos, resp.len(), "no unconsumed bytes expected");
1651    }
1652
1653    #[test]
1654    fn push_notification_rejects_data_deeplink() {
1655        let mut api = HostApi::new();
1656        let req = make_push_notification_request(
1657            "pn-data",
1658            "hi",
1659            Some("data:text/html,<script>alert(1)</script>"),
1660        );
1661        let resp = expect_response(api.handle_message(&req, TEST_APP));
1662
1663        let mut r = codec::Reader::new(&resp);
1664        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1665        assert_eq!(r.read_string().unwrap(), "pn-data");
1666        assert_eq!(r.read_u8().unwrap(), TAG_PUSH_NOTIFICATION_RESP);
1667        assert_eq!(r.read_u8().unwrap(), 0); // v1
1668        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1669        assert_eq!(r.read_u8().unwrap(), 0); // error variant 0
1670        assert_eq!(r.pos, resp.len(), "no unconsumed bytes expected");
1671    }
1672
1673    #[test]
1674    fn push_notification_rejects_plain_http_deeplink() {
1675        let mut api = HostApi::new();
1676        let req =
1677            make_push_notification_request("pn-http", "hi", Some("http://app.example/tx/123"));
1678        let resp = expect_response(api.handle_message(&req, TEST_APP));
1679
1680        let mut r = codec::Reader::new(&resp);
1681        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1682        assert_eq!(r.read_string().unwrap(), "pn-http");
1683        assert_eq!(r.read_u8().unwrap(), TAG_PUSH_NOTIFICATION_RESP);
1684        assert_eq!(r.read_u8().unwrap(), 0); // v1
1685        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1686        assert_eq!(r.read_u8().unwrap(), 0); // error variant 0
1687        assert_eq!(r.pos, resp.len(), "no unconsumed bytes expected");
1688    }
1689
1690    #[test]
1691    fn push_notification_allows_https_deeplink() {
1692        let mut api = HostApi::new();
1693        let req =
1694            make_push_notification_request("pn-https", "hi", Some("https://app.example/tx/123"));
1695
1696        match api.handle_message(&req, TEST_APP) {
1697            HostApiOutcome::NeedsPushNotification {
1698                request_id,
1699                deeplink,
1700                ..
1701            } => {
1702                assert_eq!(request_id, "pn-https");
1703                assert_eq!(deeplink.as_deref(), Some("https://app.example/tx/123"));
1704            }
1705            other => panic!(
1706                "expected NeedsPushNotification, got {}",
1707                outcome_name(&other)
1708            ),
1709        }
1710    }
1711
1712    #[test]
1713    fn push_notification_scheme_check_is_case_insensitive() {
1714        let mut api = HostApi::new();
1715        let req = make_push_notification_request("pn-case", "hi", Some("HTTPS://app.example"));
1716
1717        match api.handle_message(&req, TEST_APP) {
1718            HostApiOutcome::NeedsPushNotification { request_id, .. } => {
1719                assert_eq!(request_id, "pn-case");
1720            }
1721            other => panic!(
1722                "expected NeedsPushNotification, got {}",
1723                outcome_name(&other)
1724            ),
1725        }
1726    }
1727
1728    #[test]
1729    fn push_notification_rejects_multibyte_text_exceeding_byte_limit() {
1730        let mut api = HostApi::new();
1731        // Each '🦊' is 4 UTF-8 bytes; 257 * 4 = 1028 bytes > 1024.
1732        let text = "🦊".repeat(257);
1733        assert_eq!(text.len(), 1028);
1734        let req = make_push_notification_request("pn-mb-big", &text, None);
1735        let resp = expect_response(api.handle_message(&req, TEST_APP));
1736
1737        let mut r = codec::Reader::new(&resp);
1738        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1739        assert_eq!(r.read_string().unwrap(), "pn-mb-big");
1740        assert_eq!(r.read_u8().unwrap(), TAG_PUSH_NOTIFICATION_RESP);
1741        assert_eq!(r.read_u8().unwrap(), 0); // v1
1742        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1743    }
1744
1745    #[test]
1746    fn push_notification_accepts_multibyte_text_at_byte_limit() {
1747        let mut api = HostApi::new();
1748        // Each '🦊' is 4 UTF-8 bytes; 256 * 4 = 1024 bytes == limit.
1749        let text = "🦊".repeat(256);
1750        assert_eq!(text.len(), 1024);
1751        let req = make_push_notification_request("pn-mb-limit", &text, None);
1752
1753        match api.handle_message(&req, TEST_APP) {
1754            HostApiOutcome::NeedsPushNotification {
1755                request_id,
1756                text: actual_text,
1757                ..
1758            } => {
1759                assert_eq!(request_id, "pn-mb-limit");
1760                assert_eq!(actual_text.len(), 1024);
1761            }
1762            other => panic!(
1763                "expected NeedsPushNotification for multibyte text at limit, got {}",
1764                outcome_name(&other)
1765            ),
1766        }
1767    }
1768
1769    #[test]
1770    fn push_notification_rejects_empty_deeplink() {
1771        let mut api = HostApi::new();
1772        let req = make_push_notification_request("pn-empty-dl", "hi", Some(""));
1773        let resp = expect_response(api.handle_message(&req, TEST_APP));
1774
1775        let mut r = codec::Reader::new(&resp);
1776        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1777        assert_eq!(r.read_string().unwrap(), "pn-empty-dl");
1778        assert_eq!(r.read_u8().unwrap(), TAG_PUSH_NOTIFICATION_RESP);
1779        assert_eq!(r.read_u8().unwrap(), 0); // v1
1780        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1781    }
1782
1783    #[test]
1784    fn push_notification_accepts_bare_https_scheme_deeplink() {
1785        // "https://" with no host passes the scheme check.
1786        // Host is responsible for further URL validation.
1787        let mut api = HostApi::new();
1788        let req = make_push_notification_request("pn-bare", "hi", Some("https://"));
1789        match api.handle_message(&req, TEST_APP) {
1790            HostApiOutcome::NeedsPushNotification { request_id, .. } => {
1791                assert_eq!(request_id, "pn-bare");
1792            }
1793            other => panic!(
1794                "expected NeedsPushNotification for bare https://, got {}",
1795                outcome_name(&other)
1796            ),
1797        }
1798    }
1799
1800    #[test]
1801    fn push_notification_accepts_deeplink_at_byte_limit() {
1802        let mut api = HostApi::new();
1803        let base = "https://app.example/";
1804        let pad = super::MAX_DEEPLINK_URL_LEN - base.len();
1805        let url = format!("{}{}", base, "a".repeat(pad));
1806        assert_eq!(url.len(), super::MAX_DEEPLINK_URL_LEN);
1807        let req = make_push_notification_request("pn-dl-limit", "hi", Some(&url));
1808        match api.handle_message(&req, TEST_APP) {
1809            HostApiOutcome::NeedsPushNotification { request_id, .. } => {
1810                assert_eq!(request_id, "pn-dl-limit");
1811            }
1812            other => panic!(
1813                "expected NeedsPushNotification at deeplink limit, got {}",
1814                outcome_name(&other)
1815            ),
1816        }
1817    }
1818
1819    #[test]
1820    fn push_notification_rejects_oversized_deeplink() {
1821        let mut api = HostApi::new();
1822        let long_url = format!(
1823            "https://app.example/{}",
1824            "a".repeat(super::MAX_DEEPLINK_URL_LEN)
1825        );
1826        let req = make_push_notification_request("pn-dl-long", "hi", Some(&long_url));
1827        let resp = expect_response(api.handle_message(&req, TEST_APP));
1828
1829        let mut r = codec::Reader::new(&resp);
1830        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1831        assert_eq!(r.read_string().unwrap(), "pn-dl-long");
1832        assert_eq!(r.read_u8().unwrap(), TAG_PUSH_NOTIFICATION_RESP);
1833        assert_eq!(r.read_u8().unwrap(), 0); // v1
1834        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1835    }
1836
1837    #[test]
1838    fn storage_read_rejects_long_key() {
1839        let mut api = HostApi::new();
1840        let long_key = "k".repeat(super::MAX_STORAGE_KEY_LENGTH + 1);
1841        let req = make_storage_read("r-longkey", &long_key);
1842        let resp = expect_response(api.handle_message(&req, TEST_APP));
1843
1844        let mut r = codec::Reader::new(&resp);
1845        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1846        assert_eq!(r.read_string().unwrap(), "r-longkey");
1847        assert_eq!(r.read_u8().unwrap(), TAG_LOCAL_STORAGE_READ_RESP);
1848        assert_eq!(r.read_u8().unwrap(), 0); // v1
1849        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1850    }
1851
1852    #[test]
1853    fn push_notification_serializes_to_json() {
1854        let outcome = HostApiOutcome::NeedsPushNotification {
1855            request_id: "pn-s".into(),
1856            text: "Hello".into(),
1857            deeplink: Some("https://app.example".into()),
1858        };
1859        let json = serde_json::to_value(&outcome).unwrap();
1860        assert_eq!(json["type"], "NeedsPushNotification");
1861        assert_eq!(json["request_id"], "pn-s");
1862        assert_eq!(json["text"], "Hello");
1863        assert_eq!(json["deeplink"], "https://app.example");
1864    }
1865
1866    #[test]
1867    fn push_notification_serializes_null_deeplink() {
1868        let outcome = HostApiOutcome::NeedsPushNotification {
1869            request_id: "pn-n".into(),
1870            text: "Hi".into(),
1871            deeplink: None,
1872        };
1873        let json = serde_json::to_value(&outcome).unwrap();
1874        assert_eq!(json["type"], "NeedsPushNotification");
1875        assert_eq!(json["deeplink"], serde_json::Value::Null);
1876    }
1877
1878    #[test]
1879    fn feature_supported_push_notification() {
1880        let mut api = HostApi::new();
1881        let req = make_feature_supported_request("fs-pn", b"push_notification");
1882        let resp = expect_response(api.handle_message(&req, TEST_APP));
1883
1884        let mut r = codec::Reader::new(&resp);
1885        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1886        assert_eq!(r.read_string().unwrap(), "fs-pn");
1887        assert_eq!(r.read_u8().unwrap(), TAG_FEATURE_SUPPORTED_RESP);
1888        assert_eq!(r.read_u8().unwrap(), 0); // v1
1889        assert_eq!(r.read_u8().unwrap(), 0); // Result::Ok
1890        assert_eq!(r.read_u8().unwrap(), 1); // true
1891    }
1892
1893    #[test]
1894    fn logging_does_not_leak_push_notification_content() {
1895        let mut api = HostApi::new();
1896        let request_id = "req-id-secret-pn";
1897        let text = "notification-secret-text-123";
1898        let deeplink = "https://secret-deeplink.example/foo";
1899        let req = make_push_notification_request(request_id, text, Some(deeplink));
1900
1901        let (outcome, logs) = capture_logs(|| api.handle_message(&req, TEST_APP));
1902
1903        match outcome {
1904            HostApiOutcome::NeedsPushNotification {
1905                request_id: actual_request_id,
1906                text: actual_text,
1907                deeplink: actual_deeplink,
1908            } => {
1909                assert_eq!(actual_request_id, request_id);
1910                assert_eq!(actual_text, text);
1911                assert_eq!(actual_deeplink.as_deref(), Some(deeplink));
1912            }
1913            other => panic!(
1914                "expected NeedsPushNotification, got {}",
1915                outcome_name(&other)
1916            ),
1917        }
1918
1919        assert_logs_contain(&logs, "request: kind=push_notification (tag=4)");
1920        assert_logs_contain(&logs, "push_notification request");
1921        assert_logs_do_not_contain(&logs, request_id);
1922        assert_logs_do_not_contain(&logs, "notification-secret-text-123");
1923        assert_logs_do_not_contain(&logs, "secret-deeplink");
1924    }
1925
1926    #[test]
1927    fn storage_clear_rejects_long_key() {
1928        let mut api = HostApi::new();
1929        let long_key = "k".repeat(super::MAX_STORAGE_KEY_LENGTH + 1);
1930        let req = make_storage_clear("c-longkey", &long_key);
1931        let resp = expect_response(api.handle_message(&req, TEST_APP));
1932
1933        let mut r = codec::Reader::new(&resp);
1934        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1935        assert_eq!(r.read_string().unwrap(), "c-longkey");
1936        assert_eq!(r.read_u8().unwrap(), TAG_LOCAL_STORAGE_CLEAR_RESP);
1937        assert_eq!(r.read_u8().unwrap(), 0); // v1
1938        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1939    }
1940
1941    #[test]
1942    fn message_without_discriminator_returns_silent() {
1943        let mut api = HostApi::new();
1944        // Build a valid handshake but WITHOUT the discriminator prefix
1945        let mut msg = Vec::new();
1946        codec::encode_string(&mut msg, "hs-no-disc");
1947        msg.push(TAG_HANDSHAKE_REQ);
1948        msg.push(0); // v1
1949        msg.push(PROTOCOL_VERSION);
1950        expect_silent(api.handle_message(&msg, TEST_APP));
1951    }
1952
1953    #[test]
1954    fn message_with_wrong_discriminator_returns_silent() {
1955        let mut api = HostApi::new();
1956        let mut msg = Vec::new();
1957        msg.push(0x02); // wrong discriminator
1958        codec::encode_string(&mut msg, "hs-bad-disc");
1959        msg.push(TAG_HANDSHAKE_REQ);
1960        msg.push(0);
1961        msg.push(PROTOCOL_VERSION);
1962        expect_silent(api.handle_message(&msg, TEST_APP));
1963    }
1964
1965    #[test]
1966    fn response_first_byte_is_discriminator() {
1967        let mut api = HostApi::new();
1968        let req = make_handshake_request("hs-disc");
1969        let resp = expect_response(api.handle_message(&req, TEST_APP));
1970        assert_eq!(resp[0], PROTOCOL_DISCRIMINATOR);
1971    }
1972
1973    #[test]
1974    fn all_response_types_carry_discriminator() {
1975        let mut api = HostApi::new();
1976        // Handshake
1977        let resp = expect_response(api.handle_message(&make_handshake_request("hs-d"), TEST_APP));
1978        assert_eq!(resp[0], PROTOCOL_DISCRIMINATOR);
1979        // Get accounts
1980        let resp =
1981            expect_response(api.handle_message(&make_get_accounts_request("acc-d"), TEST_APP));
1982        assert_eq!(resp[0], PROTOCOL_DISCRIMINATOR);
1983        // Storage responses encoded by the host
1984        let resp = encode_storage_write_response("sw-d", false);
1985        assert_eq!(resp[0], PROTOCOL_DISCRIMINATOR);
1986        let resp = encode_storage_read_response("sr-d", Some(b"v"));
1987        assert_eq!(resp[0], PROTOCOL_DISCRIMINATOR);
1988        // Feature supported
1989        let resp = expect_response(api.handle_message(
1990            &make_feature_supported_request("fs-d", b"signing"),
1991            TEST_APP,
1992        ));
1993        assert_eq!(resp[0], PROTOCOL_DISCRIMINATOR);
1994        let resp = encode_storage_write_response("sc-d", true);
1995        assert_eq!(resp[0], PROTOCOL_DISCRIMINATOR);
1996    }
1997}