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,
15    encode_storage_read_response, encode_storage_write_response, Account, HostRequest,
16    HostResponse, PROTOCOL_VERSION, TAG_ACCOUNT_STATUS_INTERRUPT, TAG_ACCOUNT_STATUS_STOP,
17    TAG_CHAIN_HEAD_BODY_REQ, TAG_CHAIN_HEAD_CALL_REQ, TAG_CHAIN_HEAD_CONTINUE_REQ,
18    TAG_CHAIN_HEAD_FOLLOW_INTERRUPT, TAG_CHAIN_HEAD_FOLLOW_STOP, TAG_CHAIN_HEAD_HEADER_REQ,
19    TAG_CHAIN_HEAD_STOP_OP_REQ, TAG_CHAIN_HEAD_STORAGE_REQ, TAG_CHAIN_HEAD_UNPIN_REQ,
20    TAG_CHAIN_SPEC_GENESIS_REQ, TAG_CHAIN_SPEC_NAME_REQ, TAG_CHAIN_SPEC_PROPS_REQ,
21    TAG_CHAT_ACTION_INTERRUPT, TAG_CHAT_ACTION_STOP, TAG_CHAT_CUSTOM_MSG_INTERRUPT,
22    TAG_CHAT_CUSTOM_MSG_STOP, TAG_CHAT_LIST_INTERRUPT, TAG_CHAT_LIST_STOP,
23    TAG_JSONRPC_SUB_INTERRUPT, TAG_JSONRPC_SUB_STOP, TAG_PREIMAGE_LOOKUP_INTERRUPT,
24    TAG_PREIMAGE_LOOKUP_STOP, TAG_STATEMENT_STORE_INTERRUPT, TAG_STATEMENT_STORE_STOP,
25};
26
27#[cfg(feature = "tracing")]
28use tracing::info_span;
29
30/// Maximum number of storage keys per app.
31const MAX_STORAGE_KEYS_PER_APP: usize = 1024;
32/// Maximum size of a single storage value (64 KB).
33const MAX_STORAGE_VALUE_SIZE: usize = 64 * 1024;
34/// Maximum storage key length (512 bytes).
35const MAX_STORAGE_KEY_LENGTH: usize = 512;
36
37/// Outcome of processing a host-api message.
38#[derive(serde::Serialize)]
39#[serde(tag = "type")]
40pub enum HostApiOutcome {
41    /// Send this response directly back to the app.
42    Response { data: Vec<u8> },
43    /// Sign request — needs wallet to produce a signature before responding.
44    NeedsSign {
45        request_id: String,
46        request_tag: u8,
47        public_key: Vec<u8>,
48        payload: Vec<u8>,
49    },
50    /// JSON-RPC query — needs routing through the chain API allowlist + RPC bridge.
51    NeedsChainQuery {
52        request_id: String,
53        method: String,
54        params: serde_json::Value,
55    },
56    /// JSON-RPC subscription — needs routing through the chain API for streaming responses.
57    NeedsChainSubscription {
58        request_id: String,
59        method: String,
60        params: serde_json::Value,
61    },
62    /// Navigation request — the workbench should open this URL (may be a .dot address).
63    NeedsNavigate { request_id: String, url: String },
64    /// Start a chainHead_v1_follow subscription for a specific chain.
65    NeedsChainFollow {
66        request_id: String,
67        genesis_hash: Vec<u8>,
68        with_runtime: bool,
69    },
70    /// A chain interaction request (header, storage, call, etc.) that needs
71    /// routing to smoldot via JSON-RPC. The response_tag and json_rpc_method
72    /// tell the workbench how to route and encode the response.
73    NeedsChainRpc {
74        request_id: String,
75        request_tag: u8,
76        genesis_hash: Vec<u8>,
77        json_rpc_method: String,
78        json_rpc_params: serde_json::Value,
79        /// The follow subscription ID from the product-SDK (opaque string).
80        follow_sub_id: Option<String>,
81    },
82    /// No response needed (fire-and-forget).
83    Silent,
84}
85
86/// Shared host implementation. Handles decoded requests, returns encoded responses.
87pub struct HostApi {
88    accounts: Vec<Account>,
89    supported_chains: std::collections::HashSet<[u8; 32]>,
90    local_storage: std::collections::HashMap<String, Vec<u8>>,
91}
92
93impl Default for HostApi {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99impl HostApi {
100    pub fn new() -> Self {
101        Self {
102            accounts: Vec::new(),
103            supported_chains: std::collections::HashSet::new(),
104            local_storage: std::collections::HashMap::new(),
105        }
106    }
107
108    /// Set the accounts that will be returned by host_get_non_product_accounts.
109    ///
110    /// SECURITY: This exposes account identifiers to any product that requests
111    /// them. Will be replaced by scoped per-product identities via host-sso.
112    pub fn set_accounts(&mut self, accounts: Vec<Account>) {
113        self.accounts = accounts;
114    }
115
116    /// Set the chain genesis hashes that this host supports.
117    /// Each hash is 32 bytes (raw, not hex-encoded).
118    pub fn set_supported_chains(&mut self, chains: impl IntoIterator<Item = [u8; 32]>) {
119        self.supported_chains = chains.into_iter().collect();
120    }
121
122    /// Process a raw binary message from the app.
123    ///
124    /// Returns `HostApiOutcome::Response` for immediate replies,
125    /// `HostApiOutcome::NeedsSign` for sign requests that need wallet approval,
126    /// or `HostApiOutcome::Silent` for fire-and-forget messages.
127    ///
128    /// `app_id` scopes local storage — each app gets its own namespace.
129    pub fn handle_message(&mut self, raw: &[u8], app_id: &str) -> HostApiOutcome {
130        #[cfg(feature = "tracing")]
131        let _span = info_span!(
132            "host_api.handle_message",
133            app_id,
134            request_tag = tracing::field::Empty,
135            request_kind = tracing::field::Empty,
136        )
137        .entered();
138
139        let (request_id, request_tag, req) = match decode_message(raw) {
140            Ok(v) => v,
141            Err(e) => {
142                log::warn!(
143                    "[hostapi] failed to decode message: kind={}",
144                    decode_error_kind(&e)
145                );
146                #[cfg(feature = "tracing")]
147                tracing::Span::current().record("request_kind", "decode_error");
148                return HostApiOutcome::Silent;
149            }
150        };
151
152        #[cfg(feature = "tracing")]
153        {
154            tracing::Span::current().record("request_tag", request_tag);
155            tracing::Span::current().record("request_kind", request_kind(&req));
156        }
157
158        log::info!(
159            "[hostapi] request: kind={} (tag={request_tag})",
160            request_kind(&req)
161        );
162
163        match req {
164            HostRequest::Handshake { version } => {
165                if version == PROTOCOL_VERSION {
166                    HostApiOutcome::Response {
167                        data: encode_response(&request_id, request_tag, &HostResponse::HandshakeOk),
168                    }
169                } else {
170                    log::warn!("[hostapi] unsupported protocol version: {version}");
171                    HostApiOutcome::Response {
172                        data: encode_response(
173                            &request_id,
174                            request_tag,
175                            &HostResponse::Error("unsupported protocol version".into()),
176                        ),
177                    }
178                }
179            }
180
181            HostRequest::GetNonProductAccounts => {
182                // SECURITY: This exposes the soft-derivation root (//wallet//sso
183                // pubkey) to any product that requests it, allowing enumeration
184                // of all product addresses. This will be replaced by the host-sso
185                // pairing flow which provides scoped, per-product identities.
186                // See docs/threat-model-soft-derivation.md scenarios 1 & 2.
187                // TODO: remove once host-sso is fully wired and VoxChat uses it.
188                log::info!(
189                    "[hostapi] returning {} non-product account(s)",
190                    self.accounts.len()
191                );
192                HostApiOutcome::Response {
193                    data: encode_response(
194                        &request_id,
195                        request_tag,
196                        &HostResponse::AccountList(self.accounts.clone()),
197                    ),
198                }
199            }
200
201            HostRequest::FeatureSupported { feature_data } => {
202                let feature_kind = feature_log_kind(&feature_data);
203                // Check string features first (signing, navigate, etc.)
204                let supported = if let Ok(s) = std::str::from_utf8(&feature_data) {
205                    let r = matches!(s, "signing" | "sign" | "navigate");
206                    r
207                } else {
208                    // Binary feature: byte 0 = type, then SCALE-encoded data.
209                    // Type 0 = chain: compact_length + genesis_hash (32 bytes)
210                    let r = if feature_data.first() == Some(&0) {
211                        codec::Reader::new(&feature_data[1..])
212                            .read_var_bytes()
213                            .ok()
214                            .and_then(|h| <[u8; 32]>::try_from(h).ok())
215                            .map(|h| self.supported_chains.contains(&h))
216                            .unwrap_or(false)
217                    } else {
218                        false
219                    };
220                    r
221                };
222                log::info!("[hostapi] feature_supported: feature={feature_kind} -> {supported}");
223                HostApiOutcome::Response {
224                    data: encode_feature_response(&request_id, supported),
225                }
226            }
227
228            HostRequest::AccountConnectionStatusStart => HostApiOutcome::Response {
229                data: encode_account_status(&request_id, true),
230            },
231
232            HostRequest::LocalStorageRead { key } => {
233                let scoped = format!("{app_id}\0{key}");
234                let value = self.local_storage.get(&scoped).map(|v| v.as_slice());
235                HostApiOutcome::Response {
236                    data: encode_storage_read_response(&request_id, value),
237                }
238            }
239
240            HostRequest::LocalStorageWrite { key, value } => {
241                if key.len() > MAX_STORAGE_KEY_LENGTH {
242                    return HostApiOutcome::Response {
243                        data: encode_response(
244                            &request_id,
245                            request_tag,
246                            &HostResponse::Error("storage key too long".into()),
247                        ),
248                    };
249                }
250                if value.len() > MAX_STORAGE_VALUE_SIZE {
251                    return HostApiOutcome::Response {
252                        data: encode_response(
253                            &request_id,
254                            request_tag,
255                            &HostResponse::Error("storage value too large".into()),
256                        ),
257                    };
258                }
259                let scoped = format!("{app_id}\0{key}");
260                // Check per-app key count (count keys with this app's prefix).
261                if !self.local_storage.contains_key(&scoped) {
262                    let prefix = format!("{app_id}\0");
263                    let app_key_count = self
264                        .local_storage
265                        .keys()
266                        .filter(|k| k.starts_with(&prefix))
267                        .count();
268                    if app_key_count >= MAX_STORAGE_KEYS_PER_APP {
269                        return HostApiOutcome::Response {
270                            data: encode_response(
271                                &request_id,
272                                request_tag,
273                                &HostResponse::Error("storage key limit reached".into()),
274                            ),
275                        };
276                    }
277                }
278                self.local_storage.insert(scoped, value);
279                HostApiOutcome::Response {
280                    data: encode_storage_write_response(&request_id, false),
281                }
282            }
283
284            HostRequest::LocalStorageClear { key } => {
285                let scoped = format!("{app_id}\0{key}");
286                self.local_storage.remove(&scoped);
287                HostApiOutcome::Response {
288                    data: encode_storage_write_response(&request_id, true),
289                }
290            }
291
292            HostRequest::NavigateTo { url } => {
293                log::info!("[hostapi] navigate_to request");
294                HostApiOutcome::NeedsNavigate { request_id, url }
295            }
296
297            HostRequest::SignPayload {
298                public_key,
299                payload,
300            } => {
301                log::info!(
302                    "[hostapi] sign_payload request (pubkey={} bytes)",
303                    public_key.len()
304                );
305                HostApiOutcome::NeedsSign {
306                    request_id,
307                    request_tag,
308                    public_key,
309                    payload,
310                }
311            }
312
313            HostRequest::SignRaw { public_key, data } => {
314                log::info!(
315                    "[hostapi] sign_raw request (pubkey={} bytes)",
316                    public_key.len()
317                );
318                HostApiOutcome::NeedsSign {
319                    request_id,
320                    request_tag,
321                    public_key,
322                    payload: data,
323                }
324            }
325
326            HostRequest::CreateTransaction { .. } => {
327                log::info!("[hostapi] create_transaction (not yet implemented)");
328                HostApiOutcome::Response {
329                    data: encode_response(
330                        &request_id,
331                        request_tag,
332                        &HostResponse::Error("create_transaction not yet implemented".into()),
333                    ),
334                }
335            }
336
337            HostRequest::JsonRpcSend { data } => {
338                // The data is a SCALE string containing a JSON-RPC request body,
339                // or raw bytes we try to interpret as UTF-8 JSON.
340                let json_str = parse_jsonrpc_data(&data);
341                match json_str {
342                    Some((method, params)) => {
343                        log::info!("[hostapi] jsonrpc_send request");
344                        HostApiOutcome::NeedsChainQuery {
345                            request_id,
346                            method,
347                            params,
348                        }
349                    }
350                    None => {
351                        log::warn!("[hostapi] failed to parse JSON-RPC send data");
352                        HostApiOutcome::Response {
353                            data: encode_response(
354                                &request_id,
355                                request_tag,
356                                &HostResponse::Error("invalid json-rpc request".into()),
357                            ),
358                        }
359                    }
360                }
361            }
362
363            HostRequest::JsonRpcSubscribeStart { data } => {
364                let json_str = parse_jsonrpc_data(&data);
365                match json_str {
366                    Some((method, params)) => {
367                        log::info!("[hostapi] jsonrpc_subscribe_start request");
368                        HostApiOutcome::NeedsChainSubscription {
369                            request_id,
370                            method,
371                            params,
372                        }
373                    }
374                    None => {
375                        log::warn!("[hostapi] failed to parse JSON-RPC subscribe data");
376                        HostApiOutcome::Response {
377                            data: encode_response(
378                                &request_id,
379                                request_tag,
380                                &HostResponse::Error("invalid json-rpc subscribe request".into()),
381                            ),
382                        }
383                    }
384                }
385            }
386
387            HostRequest::ChainHeadFollowStart {
388                genesis_hash,
389                with_runtime,
390            } => {
391                log::info!("[hostapi] chainHead follow start (genesis={} bytes, withRuntime={with_runtime})", genesis_hash.len());
392                HostApiOutcome::NeedsChainFollow {
393                    request_id,
394                    genesis_hash,
395                    with_runtime,
396                }
397            }
398
399            HostRequest::ChainHeadRequest {
400                tag,
401                genesis_hash,
402                follow_sub_id,
403                data,
404            } => {
405                let method = match tag {
406                    TAG_CHAIN_HEAD_HEADER_REQ => "chainHead_v1_header",
407                    TAG_CHAIN_HEAD_BODY_REQ => "chainHead_v1_body",
408                    TAG_CHAIN_HEAD_STORAGE_REQ => "chainHead_v1_storage",
409                    TAG_CHAIN_HEAD_CALL_REQ => "chainHead_v1_call",
410                    TAG_CHAIN_HEAD_UNPIN_REQ => "chainHead_v1_unpin",
411                    TAG_CHAIN_HEAD_CONTINUE_REQ => "chainHead_v1_continue",
412                    TAG_CHAIN_HEAD_STOP_OP_REQ => "chainHead_v1_stopOperation",
413                    _ => {
414                        log::warn!("[hostapi] unknown chain head request tag: {tag}");
415                        return HostApiOutcome::Silent;
416                    }
417                };
418                log::info!("[hostapi] chain request: {method} (tag={tag})");
419                HostApiOutcome::NeedsChainRpc {
420                    request_id,
421                    request_tag: tag,
422                    genesis_hash,
423                    json_rpc_method: method.into(),
424                    json_rpc_params: data,
425                    follow_sub_id: Some(follow_sub_id),
426                }
427            }
428
429            HostRequest::ChainSpecRequest { tag, genesis_hash } => {
430                let method = match tag {
431                    TAG_CHAIN_SPEC_GENESIS_REQ => "chainSpec_v1_genesisHash",
432                    TAG_CHAIN_SPEC_NAME_REQ => "chainSpec_v1_chainName",
433                    TAG_CHAIN_SPEC_PROPS_REQ => "chainSpec_v1_properties",
434                    _ => {
435                        log::warn!("[hostapi] unknown chainSpec request tag: {tag}");
436                        return HostApiOutcome::Silent;
437                    }
438                };
439                log::info!("[hostapi] chainSpec request: {method} (tag={tag})");
440                HostApiOutcome::NeedsChainRpc {
441                    request_id,
442                    request_tag: tag,
443                    genesis_hash,
444                    json_rpc_method: method.into(),
445                    json_rpc_params: serde_json::Value::Array(vec![]),
446                    follow_sub_id: None,
447                }
448            }
449
450            HostRequest::ChainTxBroadcast {
451                genesis_hash,
452                transaction,
453            } => {
454                log::info!("[hostapi] transaction broadcast");
455                let tx_hex = chain::bytes_to_hex(&transaction);
456                HostApiOutcome::NeedsChainRpc {
457                    request_id,
458                    request_tag,
459                    genesis_hash,
460                    json_rpc_method: "transaction_v1_broadcast".into(),
461                    json_rpc_params: serde_json::json!([tx_hex]),
462                    follow_sub_id: None,
463                }
464            }
465
466            HostRequest::ChainTxStop {
467                genesis_hash,
468                operation_id,
469            } => {
470                log::info!("[hostapi] transaction stop");
471                HostApiOutcome::NeedsChainRpc {
472                    request_id,
473                    request_tag,
474                    genesis_hash,
475                    json_rpc_method: "transaction_v1_stop".into(),
476                    json_rpc_params: serde_json::json!([operation_id]),
477                    follow_sub_id: None,
478                }
479            }
480
481            HostRequest::Unimplemented { tag } => {
482                log::info!("[hostapi] unimplemented method (tag={tag})");
483                if is_subscription_control(tag) {
484                    HostApiOutcome::Silent
485                } else {
486                    HostApiOutcome::Response {
487                        data: encode_response(
488                            &request_id,
489                            request_tag,
490                            &HostResponse::Error("not implemented".into()),
491                        ),
492                    }
493                }
494            }
495
496            HostRequest::Unknown { tag } => {
497                log::warn!("[hostapi] unknown tag: {tag}");
498                HostApiOutcome::Silent
499            }
500        }
501    }
502}
503
504/// Parse the `data` field from a `JsonRpcSend` request.
505///
506/// The Product SDK encodes this as a SCALE string containing the full JSON-RPC
507/// request (e.g. `{"jsonrpc":"2.0","id":1,"method":"state_getMetadata","params":[]}`).
508/// We try SCALE string first, then fall back to raw UTF-8.
509fn parse_jsonrpc_data(data: &[u8]) -> Option<(String, serde_json::Value)> {
510    // Try SCALE string (compact length + UTF-8 bytes).
511    let json_str = codec::Reader::new(data)
512        .read_string()
513        .ok()
514        .or_else(|| std::str::from_utf8(data).ok().map(|s| s.to_string()))?;
515
516    let v: serde_json::Value = serde_json::from_str(&json_str).ok()?;
517    let method = v.get("method")?.as_str()?.to_string();
518    let params = v
519        .get("params")
520        .cloned()
521        .unwrap_or(serde_json::Value::Array(vec![]));
522    Some((method, params))
523}
524
525/// Check if a tag is a subscription control message (stop/interrupt).
526fn is_subscription_control(tag: u8) -> bool {
527    matches!(
528        tag,
529        TAG_ACCOUNT_STATUS_STOP
530            | TAG_ACCOUNT_STATUS_INTERRUPT
531            | TAG_CHAT_LIST_STOP
532            | TAG_CHAT_LIST_INTERRUPT
533            | TAG_CHAT_ACTION_STOP
534            | TAG_CHAT_ACTION_INTERRUPT
535            | TAG_CHAT_CUSTOM_MSG_STOP
536            | TAG_CHAT_CUSTOM_MSG_INTERRUPT
537            | TAG_STATEMENT_STORE_STOP
538            | TAG_STATEMENT_STORE_INTERRUPT
539            | TAG_PREIMAGE_LOOKUP_STOP
540            | TAG_PREIMAGE_LOOKUP_INTERRUPT
541            | TAG_JSONRPC_SUB_STOP
542            | TAG_JSONRPC_SUB_INTERRUPT
543            | TAG_CHAIN_HEAD_FOLLOW_STOP
544            | TAG_CHAIN_HEAD_FOLLOW_INTERRUPT
545    )
546}
547
548fn decode_error_kind(err: &codec::DecodeErr) -> &'static str {
549    match err {
550        codec::DecodeErr::Eof => "eof",
551        codec::DecodeErr::CompactTooLarge => "compact_too_large",
552        codec::DecodeErr::InvalidUtf8 => "invalid_utf8",
553        codec::DecodeErr::InvalidOption => "invalid_option",
554        codec::DecodeErr::InvalidTag(_) => "invalid_tag",
555        codec::DecodeErr::BadMessage(_) => "bad_message",
556    }
557}
558
559fn feature_log_kind(feature_data: &[u8]) -> &'static str {
560    match std::str::from_utf8(feature_data) {
561        Ok("signing" | "sign" | "navigate") => "utf8_known",
562        Ok(_) => "utf8_other",
563        Err(_) if feature_data.first() == Some(&0) => "binary_chain",
564        Err(_) => "binary_other",
565    }
566}
567
568fn request_kind(req: &HostRequest) -> &'static str {
569    match req {
570        HostRequest::Handshake { .. } => "handshake",
571        HostRequest::GetNonProductAccounts => "get_non_product_accounts",
572        HostRequest::FeatureSupported { .. } => "feature_supported",
573        HostRequest::LocalStorageRead { .. } => "local_storage_read",
574        HostRequest::LocalStorageWrite { .. } => "local_storage_write",
575        HostRequest::LocalStorageClear { .. } => "local_storage_clear",
576        HostRequest::SignPayload { .. } => "sign_payload",
577        HostRequest::SignRaw { .. } => "sign_raw",
578        HostRequest::CreateTransaction { .. } => "create_transaction",
579        HostRequest::NavigateTo { .. } => "navigate_to",
580        HostRequest::AccountConnectionStatusStart => "account_connection_status_start",
581        HostRequest::JsonRpcSend { .. } => "jsonrpc_send",
582        HostRequest::JsonRpcSubscribeStart { .. } => "jsonrpc_subscribe_start",
583        HostRequest::ChainHeadFollowStart { .. } => "chain_head_follow_start",
584        HostRequest::ChainHeadRequest { .. } => "chain_head_request",
585        HostRequest::ChainSpecRequest { .. } => "chain_spec_request",
586        HostRequest::ChainTxBroadcast { .. } => "chain_tx_broadcast",
587        HostRequest::ChainTxStop { .. } => "chain_tx_stop",
588        HostRequest::Unimplemented { .. } => "unimplemented",
589        HostRequest::Unknown { .. } => "unknown",
590    }
591}
592
593// ---------------------------------------------------------------------------
594// JS bridge script — injected into WKWebView at document_start
595// ---------------------------------------------------------------------------
596
597/// JavaScript injected before the Polkadot app loads. Sets up:
598/// 1. `window.__HOST_WEBVIEW_MARK__ = true` — SDK webview detection
599/// 2. `MessageChannel` with port2 as `window.__HOST_API_PORT__`
600/// 3. Binary message forwarding between port1 and native (base64)
601pub const HOST_API_BRIDGE_SCRIPT: &str = r#"
602(function() {
603    'use strict';
604    if (window.__hostApiBridge) { return; }
605    window.__hostApiBridge = true;
606    window.__HOST_WEBVIEW_MARK__ = true;
607    var ch = new MessageChannel();
608    window.__HOST_API_PORT__ = ch.port2;
609    ch.port2.start();
610    var port1 = ch.port1;
611    port1.start();
612    port1.onmessage = function(ev) {
613        var data = ev.data;
614        if (!data) { console.warn('[host-bridge] data is falsy, dropping'); return; }
615        var bytes;
616        if (data instanceof Uint8Array) { bytes = data; }
617        else if (data instanceof ArrayBuffer) { bytes = new Uint8Array(data); }
618        else if (ArrayBuffer.isView(data)) { bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); }
619        else { console.warn('[host-bridge] unknown data type: ' + typeof data + ' constructor=' + (data.constructor ? data.constructor.name : '?') + ', dropping'); return; }
620        var binary = '';
621        for (var i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); }
622        try {
623            window.webkit.messageHandlers.hostApi.postMessage(btoa(binary));
624        } catch(e) {
625            console.error('[host-bridge] postMessage to hostApi FAILED:', e.message);
626        }
627    };
628    window.__hostApiReply = function(b64) {
629        try {
630            var binary = atob(b64);
631            var bytes = new Uint8Array(binary.length);
632            for (var i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); }
633            port1.postMessage(bytes);
634        } catch(e) { console.error('[host-bridge] reply failed:', e.message); }
635    };
636})();
637"#;
638
639#[cfg(test)]
640mod tests {
641    use super::*;
642    use crate::protocol::*;
643    use std::sync::atomic::{AtomicBool, Ordering};
644    use std::sync::{Mutex, Once};
645
646    const TEST_APP: &str = "test-app";
647    static TEST_LOGGER_INIT: Once = Once::new();
648    static TEST_LOGGER_INSTALLED: AtomicBool = AtomicBool::new(false);
649    static TEST_LOG_CAPTURE_LOCK: Mutex<()> = Mutex::new(());
650    static TEST_LOGGER: TestLogger = TestLogger::new();
651
652    struct TestLogger {
653        entries: Mutex<Vec<String>>,
654        capture_thread: Mutex<Option<std::thread::ThreadId>>,
655    }
656
657    impl TestLogger {
658        const fn new() -> Self {
659            Self {
660                entries: Mutex::new(Vec::new()),
661                capture_thread: Mutex::new(None),
662            }
663        }
664    }
665
666    impl log::Log for TestLogger {
667        fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
668            metadata.level() <= log::Level::Info
669        }
670
671        fn log(&self, record: &log::Record<'_>) {
672            if !self.enabled(record.metadata()) {
673                return;
674            }
675
676            let capture_thread = self
677                .capture_thread
678                .lock()
679                .unwrap_or_else(|e| e.into_inner());
680            if capture_thread.as_ref() != Some(&std::thread::current().id()) {
681                return;
682            }
683            drop(capture_thread);
684
685            self.entries
686                .lock()
687                .unwrap_or_else(|e| e.into_inner())
688                .push(record.args().to_string());
689        }
690
691        fn flush(&self) {}
692    }
693
694    fn capture_logs<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
695        TEST_LOGGER_INIT.call_once(|| {
696            let installed = log::set_logger(&TEST_LOGGER).is_ok();
697            if installed {
698                log::set_max_level(log::LevelFilter::Info);
699            }
700            TEST_LOGGER_INSTALLED.store(installed, Ordering::Relaxed);
701        });
702        assert!(
703            TEST_LOGGER_INSTALLED.load(Ordering::Relaxed),
704            "test logger could not be installed"
705        );
706
707        let _guard = TEST_LOG_CAPTURE_LOCK
708            .lock()
709            .unwrap_or_else(|e| e.into_inner());
710
711        TEST_LOGGER
712            .entries
713            .lock()
714            .unwrap_or_else(|e| e.into_inner())
715            .clear();
716        *TEST_LOGGER
717            .capture_thread
718            .lock()
719            .unwrap_or_else(|e| e.into_inner()) = Some(std::thread::current().id());
720
721        let result = f();
722
723        *TEST_LOGGER
724            .capture_thread
725            .lock()
726            .unwrap_or_else(|e| e.into_inner()) = None;
727        let logs = TEST_LOGGER
728            .entries
729            .lock()
730            .unwrap_or_else(|e| e.into_inner())
731            .clone();
732        TEST_LOGGER
733            .entries
734            .lock()
735            .unwrap_or_else(|e| e.into_inner())
736            .clear();
737
738        (result, logs)
739    }
740
741    fn assert_logs_contain(logs: &[String], needle: &str) {
742        let joined = logs.join("\n");
743        assert!(
744            joined.contains(needle),
745            "expected logs to contain {needle:?}, got:\n{joined}"
746        );
747    }
748
749    fn assert_logs_do_not_contain(logs: &[String], needle: &str) {
750        let joined = logs.join("\n");
751        assert!(
752            !joined.contains(needle),
753            "expected logs not to contain {needle:?}, got:\n{joined}"
754        );
755    }
756
757    /// Extract a Response from HostApiOutcome, panicking on other variants.
758    fn expect_response(outcome: HostApiOutcome) -> Vec<u8> {
759        match outcome {
760            HostApiOutcome::Response { data: v } => v,
761            other => panic!("expected Response, got {}", outcome_name(&other)),
762        }
763    }
764
765    fn expect_silent(outcome: HostApiOutcome) {
766        match outcome {
767            HostApiOutcome::Silent => {}
768            other => panic!("expected Silent, got {}", outcome_name(&other)),
769        }
770    }
771
772    fn outcome_name(o: &HostApiOutcome) -> &'static str {
773        match o {
774            HostApiOutcome::Response { .. } => "Response",
775            HostApiOutcome::NeedsSign { .. } => "NeedsSign",
776            HostApiOutcome::NeedsChainQuery { .. } => "NeedsChainQuery",
777            HostApiOutcome::NeedsChainSubscription { .. } => "NeedsChainSubscription",
778            HostApiOutcome::NeedsNavigate { .. } => "NeedsNavigate",
779            HostApiOutcome::NeedsChainFollow { .. } => "NeedsChainFollow",
780            HostApiOutcome::NeedsChainRpc { .. } => "NeedsChainRpc",
781            HostApiOutcome::Silent => "Silent",
782        }
783    }
784
785    fn make_handshake_request(request_id: &str) -> Vec<u8> {
786        let mut msg = Vec::new();
787        codec::encode_string(&mut msg, request_id);
788        msg.push(TAG_HANDSHAKE_REQ);
789        msg.push(0); // v1
790        msg.push(PROTOCOL_VERSION);
791        msg
792    }
793
794    fn make_get_accounts_request(request_id: &str) -> Vec<u8> {
795        let mut msg = Vec::new();
796        codec::encode_string(&mut msg, request_id);
797        msg.push(TAG_GET_NON_PRODUCT_ACCOUNTS_REQ);
798        msg.push(0); // v1
799        msg
800    }
801
802    fn make_storage_write(request_id: &str, key: &str, value: &[u8]) -> Vec<u8> {
803        let mut msg = Vec::new();
804        codec::encode_string(&mut msg, request_id);
805        msg.push(TAG_LOCAL_STORAGE_WRITE_REQ);
806        msg.push(0); // v1
807        codec::encode_string(&mut msg, key);
808        codec::encode_var_bytes(&mut msg, value);
809        msg
810    }
811
812    fn make_storage_read(request_id: &str, key: &str) -> Vec<u8> {
813        let mut msg = Vec::new();
814        codec::encode_string(&mut msg, request_id);
815        msg.push(TAG_LOCAL_STORAGE_READ_REQ);
816        msg.push(0); // v1
817        codec::encode_string(&mut msg, key);
818        msg
819    }
820
821    fn make_storage_clear(request_id: &str, key: &str) -> Vec<u8> {
822        let mut msg = Vec::new();
823        codec::encode_string(&mut msg, request_id);
824        msg.push(TAG_LOCAL_STORAGE_CLEAR_REQ);
825        msg.push(0); // v1
826        codec::encode_string(&mut msg, key);
827        msg
828    }
829
830    fn make_feature_supported_request(request_id: &str, feature_data: &[u8]) -> Vec<u8> {
831        let mut msg = Vec::new();
832        codec::encode_string(&mut msg, request_id);
833        msg.push(TAG_FEATURE_SUPPORTED_REQ);
834        msg.push(0); // v1
835        msg.extend_from_slice(feature_data);
836        msg
837    }
838
839    fn make_navigate_request(request_id: &str, url: &str) -> Vec<u8> {
840        let mut msg = Vec::new();
841        codec::encode_string(&mut msg, request_id);
842        msg.push(TAG_NAVIGATE_TO_REQ);
843        msg.push(0); // v1
844        codec::encode_string(&mut msg, url);
845        msg
846    }
847
848    fn make_jsonrpc_request(request_id: &str, tag: u8, json: &str) -> Vec<u8> {
849        let mut msg = Vec::new();
850        codec::encode_string(&mut msg, request_id);
851        msg.push(tag);
852        msg.push(0); // v1
853        codec::encode_string(&mut msg, json);
854        msg
855    }
856
857    #[test]
858    fn handshake_flow() {
859        let mut api = HostApi::new();
860        let req = make_handshake_request("hs-1");
861        let resp = expect_response(api.handle_message(&req, TEST_APP));
862
863        let mut r = codec::Reader::new(&resp);
864        assert_eq!(r.read_string().unwrap(), "hs-1");
865        assert_eq!(r.read_u8().unwrap(), TAG_HANDSHAKE_RESP);
866        assert_eq!(r.read_u8().unwrap(), 0); // v1
867        assert_eq!(r.read_u8().unwrap(), 0); // Result::Ok
868    }
869
870    #[test]
871    fn handshake_wrong_version() {
872        let mut api = HostApi::new();
873        let mut req = Vec::new();
874        codec::encode_string(&mut req, "hs-bad");
875        req.push(TAG_HANDSHAKE_REQ);
876        req.push(0); // v1
877        req.push(255); // wrong version
878        let resp = expect_response(api.handle_message(&req, TEST_APP));
879
880        let mut r = codec::Reader::new(&resp);
881        assert_eq!(r.read_string().unwrap(), "hs-bad");
882        assert_eq!(r.read_u8().unwrap(), TAG_HANDSHAKE_RESP);
883        assert_eq!(r.read_u8().unwrap(), 0); // v1
884        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
885    }
886
887    #[test]
888    fn request_kind_redacts_payload_variants() {
889        assert_eq!(
890            request_kind(&HostRequest::SignPayload {
891                public_key: vec![0xAA; 32],
892                payload: b"secret-payload".to_vec(),
893            }),
894            "sign_payload"
895        );
896        assert_eq!(
897            request_kind(&HostRequest::NavigateTo {
898                url: "https://example.com/private".into(),
899            }),
900            "navigate_to"
901        );
902        assert_eq!(
903            request_kind(&HostRequest::LocalStorageWrite {
904                key: "secret".into(),
905                value: b"top-secret".to_vec(),
906            }),
907            "local_storage_write"
908        );
909    }
910
911    #[test]
912    fn decode_error_kind_redacts_error_details() {
913        assert_eq!(
914            decode_error_kind(&codec::DecodeErr::BadMessage("secret")),
915            "bad_message"
916        );
917        assert_eq!(
918            decode_error_kind(&codec::DecodeErr::InvalidTag(255)),
919            "invalid_tag"
920        );
921    }
922
923    #[test]
924    fn feature_log_kind_redacts_feature_data() {
925        assert_eq!(feature_log_kind(b"signing"), "utf8_known");
926        assert_eq!(feature_log_kind(b"secret-feature"), "utf8_other");
927        assert_eq!(
928            feature_log_kind(&[0, 0xde, 0xad, 0xbe, 0xef]),
929            "binary_chain"
930        );
931        assert_eq!(
932            feature_log_kind(&[1, 0xde, 0xad, 0xbe, 0xef]),
933            "binary_other"
934        );
935    }
936
937    #[test]
938    fn get_accounts_empty() {
939        let mut api = HostApi::new();
940        let req = make_get_accounts_request("acc-1");
941        let resp = expect_response(api.handle_message(&req, TEST_APP));
942
943        let mut r = codec::Reader::new(&resp);
944        assert_eq!(r.read_string().unwrap(), "acc-1");
945        assert_eq!(r.read_u8().unwrap(), TAG_GET_NON_PRODUCT_ACCOUNTS_RESP);
946        assert_eq!(r.read_u8().unwrap(), 0); // v1
947        assert_eq!(r.read_u8().unwrap(), 0); // Result::Ok
948        assert_eq!(r.read_compact_u32().unwrap(), 0); // empty vector
949    }
950
951    #[test]
952    fn get_accounts_returns_configured_accounts() {
953        let mut api = HostApi::new();
954        api.set_accounts(vec![Account {
955            public_key: vec![0xAA; 32],
956            name: Some("Test Account".into()),
957        }]);
958
959        let req = make_get_accounts_request("acc-2");
960        let resp = expect_response(api.handle_message(&req, TEST_APP));
961
962        let mut r = codec::Reader::new(&resp);
963        assert_eq!(r.read_string().unwrap(), "acc-2");
964        assert_eq!(r.read_u8().unwrap(), TAG_GET_NON_PRODUCT_ACCOUNTS_RESP);
965        assert_eq!(r.read_u8().unwrap(), 0); // v1
966        assert_eq!(r.read_u8().unwrap(), 0); // Result::Ok
967        assert_eq!(r.read_compact_u32().unwrap(), 1); // 1 account
968    }
969
970    #[test]
971    fn local_storage_round_trip() {
972        let mut api = HostApi::new();
973
974        expect_response(
975            api.handle_message(&make_storage_write("w-1", "mykey", b"myvalue"), TEST_APP),
976        );
977
978        let resp =
979            expect_response(api.handle_message(&make_storage_read("r-1", "mykey"), TEST_APP));
980
981        let mut r = codec::Reader::new(&resp);
982        assert_eq!(r.read_string().unwrap(), "r-1");
983        assert_eq!(r.read_u8().unwrap(), TAG_LOCAL_STORAGE_READ_RESP);
984        assert_eq!(r.read_u8().unwrap(), 0); // v1
985        assert_eq!(r.read_u8().unwrap(), 0); // Result::Ok
986        let val = r.read_option(|r| r.read_var_bytes()).unwrap();
987        assert_eq!(val.as_deref(), Some(b"myvalue".as_ref()));
988    }
989
990    #[test]
991    fn local_storage_read_missing_key() {
992        let mut api = HostApi::new();
993        let resp = expect_response(
994            api.handle_message(&make_storage_read("r-miss", "nonexistent"), TEST_APP),
995        );
996
997        let mut r = codec::Reader::new(&resp);
998        assert_eq!(r.read_string().unwrap(), "r-miss");
999        assert_eq!(r.read_u8().unwrap(), TAG_LOCAL_STORAGE_READ_RESP);
1000        assert_eq!(r.read_u8().unwrap(), 0); // v1
1001        assert_eq!(r.read_u8().unwrap(), 0); // Result::Ok
1002        let val = r.read_option(|r| r.read_var_bytes()).unwrap();
1003        assert!(val.is_none());
1004    }
1005
1006    #[test]
1007    fn local_storage_clear() {
1008        let mut api = HostApi::new();
1009
1010        // Write then clear
1011        api.handle_message(&make_storage_write("w-2", "clearme", b"data"), TEST_APP);
1012        let resp =
1013            expect_response(api.handle_message(&make_storage_clear("c-1", "clearme"), TEST_APP));
1014
1015        let mut r = codec::Reader::new(&resp);
1016        assert_eq!(r.read_string().unwrap(), "c-1");
1017        assert_eq!(r.read_u8().unwrap(), TAG_LOCAL_STORAGE_CLEAR_RESP);
1018        assert_eq!(r.read_u8().unwrap(), 0); // v1
1019        assert_eq!(r.read_u8().unwrap(), 0); // Result::Ok
1020
1021        // Verify key is gone
1022        let resp2 =
1023            expect_response(api.handle_message(&make_storage_read("r-2", "clearme"), TEST_APP));
1024        let mut r2 = codec::Reader::new(&resp2);
1025        r2.read_string().unwrap();
1026        r2.read_u8().unwrap();
1027        r2.read_u8().unwrap();
1028        r2.read_u8().unwrap();
1029        let val = r2.read_option(|r| r.read_var_bytes()).unwrap();
1030        assert!(val.is_none());
1031    }
1032
1033    #[test]
1034    fn local_storage_isolation_between_apps() {
1035        let mut api = HostApi::new();
1036
1037        // App A writes a key
1038        api.handle_message(&make_storage_write("w-a", "shared", b"from_a"), "app-a");
1039
1040        // App B reads the same key name — should get None
1041        let resp =
1042            expect_response(api.handle_message(&make_storage_read("r-b", "shared"), "app-b"));
1043        let mut r = codec::Reader::new(&resp);
1044        r.read_string().unwrap();
1045        r.read_u8().unwrap();
1046        r.read_u8().unwrap();
1047        r.read_u8().unwrap();
1048        let val = r.read_option(|r| r.read_var_bytes()).unwrap();
1049        assert!(val.is_none(), "app-b should not see app-a's data");
1050
1051        // App A reads its own key — should get the value
1052        let resp2 =
1053            expect_response(api.handle_message(&make_storage_read("r-a", "shared"), "app-a"));
1054        let mut r2 = codec::Reader::new(&resp2);
1055        r2.read_string().unwrap();
1056        r2.read_u8().unwrap();
1057        r2.read_u8().unwrap();
1058        r2.read_u8().unwrap();
1059        let val2 = r2.read_option(|r| r.read_var_bytes()).unwrap();
1060        assert_eq!(val2.as_deref(), Some(b"from_a".as_ref()));
1061    }
1062
1063    #[test]
1064    fn unimplemented_request_returns_error() {
1065        let mut api = HostApi::new();
1066        let mut msg = Vec::new();
1067        codec::encode_string(&mut msg, "unimp-1");
1068        msg.push(TAG_PUSH_NOTIFICATION_REQ);
1069
1070        let resp = expect_response(api.handle_message(&msg, TEST_APP));
1071
1072        let mut r = codec::Reader::new(&resp);
1073        assert_eq!(r.read_string().unwrap(), "unimp-1");
1074        assert_eq!(r.read_u8().unwrap(), TAG_PUSH_NOTIFICATION_RESP);
1075        assert_eq!(r.read_u8().unwrap(), 0); // v1
1076        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1077    }
1078
1079    #[test]
1080    fn subscription_stop_returns_silent() {
1081        let mut api = HostApi::new();
1082        let mut msg = Vec::new();
1083        codec::encode_string(&mut msg, "stop-1");
1084        msg.push(TAG_ACCOUNT_STATUS_STOP);
1085
1086        expect_silent(api.handle_message(&msg, TEST_APP));
1087    }
1088
1089    #[test]
1090    fn malformed_input_returns_silent() {
1091        let mut api = HostApi::new();
1092
1093        // Empty input
1094        expect_silent(api.handle_message(&[], TEST_APP));
1095
1096        // Truncated after request_id
1097        let mut msg = Vec::new();
1098        codec::encode_string(&mut msg, "trunc");
1099        expect_silent(api.handle_message(&msg, TEST_APP));
1100    }
1101
1102    #[test]
1103    fn unknown_tag_returns_silent() {
1104        let mut api = HostApi::new();
1105        let mut msg = Vec::new();
1106        codec::encode_string(&mut msg, "unk-1");
1107        msg.push(0xFF); // unknown tag
1108        expect_silent(api.handle_message(&msg, TEST_APP));
1109    }
1110
1111    #[test]
1112    fn storage_write_rejects_oversized_value() {
1113        let mut api = HostApi::new();
1114        let big_value = vec![0xAA; super::MAX_STORAGE_VALUE_SIZE + 1];
1115        let req = make_storage_write("w-big", "key", &big_value);
1116        let resp = expect_response(api.handle_message(&req, TEST_APP));
1117
1118        let mut r = codec::Reader::new(&resp);
1119        assert_eq!(r.read_string().unwrap(), "w-big");
1120        assert_eq!(r.read_u8().unwrap(), TAG_LOCAL_STORAGE_WRITE_RESP);
1121        assert_eq!(r.read_u8().unwrap(), 0); // v1
1122        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1123    }
1124
1125    #[test]
1126    fn storage_write_rejects_long_key() {
1127        let mut api = HostApi::new();
1128        let long_key = "k".repeat(super::MAX_STORAGE_KEY_LENGTH + 1);
1129        let req = make_storage_write("w-longkey", &long_key, b"v");
1130        let resp = expect_response(api.handle_message(&req, TEST_APP));
1131
1132        let mut r = codec::Reader::new(&resp);
1133        assert_eq!(r.read_string().unwrap(), "w-longkey");
1134        assert_eq!(r.read_u8().unwrap(), TAG_LOCAL_STORAGE_WRITE_RESP);
1135        assert_eq!(r.read_u8().unwrap(), 0); // v1
1136        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1137    }
1138
1139    #[test]
1140    fn storage_write_enforces_key_limit() {
1141        let mut api = HostApi::new();
1142        // Fill up to the limit.
1143        for i in 0..super::MAX_STORAGE_KEYS_PER_APP {
1144            let key = format!("key-{i}");
1145            let req = make_storage_write(&format!("w-{i}"), &key, b"v");
1146            expect_response(api.handle_message(&req, TEST_APP));
1147        }
1148        // The next key should be rejected.
1149        let req = make_storage_write("w-over", "one-too-many", b"v");
1150        let resp = expect_response(api.handle_message(&req, TEST_APP));
1151
1152        let mut r = codec::Reader::new(&resp);
1153        assert_eq!(r.read_string().unwrap(), "w-over");
1154        assert_eq!(r.read_u8().unwrap(), TAG_LOCAL_STORAGE_WRITE_RESP);
1155        assert_eq!(r.read_u8().unwrap(), 0); // v1
1156        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1157
1158        // Overwriting an existing key should still work.
1159        let req = make_storage_write("w-update", "key-0", b"new-value");
1160        let resp = expect_response(api.handle_message(&req, TEST_APP));
1161        let mut r = codec::Reader::new(&resp);
1162        assert_eq!(r.read_string().unwrap(), "w-update");
1163        assert_eq!(r.read_u8().unwrap(), TAG_LOCAL_STORAGE_WRITE_RESP);
1164        assert_eq!(r.read_u8().unwrap(), 0); // v1
1165        assert_eq!(r.read_u8().unwrap(), 0); // Result::Ok
1166    }
1167
1168    #[test]
1169    fn sign_payload_returns_needs_sign() {
1170        let mut api = HostApi::new();
1171        let mut msg = Vec::new();
1172        codec::encode_string(&mut msg, "sign-1");
1173        msg.push(TAG_SIGN_PAYLOAD_REQ);
1174        msg.push(0); // v1
1175        codec::encode_var_bytes(&mut msg, &[0xAA; 32]); // publicKey
1176        msg.extend_from_slice(b"payload-data");
1177
1178        match api.handle_message(&msg, TEST_APP) {
1179            HostApiOutcome::NeedsSign {
1180                request_id,
1181                request_tag,
1182                public_key,
1183                payload,
1184            } => {
1185                assert_eq!(request_id, "sign-1");
1186                assert_eq!(request_tag, TAG_SIGN_PAYLOAD_REQ);
1187                assert_eq!(public_key, vec![0xAA; 32]);
1188                assert_eq!(payload, b"payload-data");
1189            }
1190            _ => panic!("expected NeedsSign"),
1191        }
1192    }
1193
1194    #[test]
1195    fn sign_raw_returns_needs_sign() {
1196        let mut api = HostApi::new();
1197        let mut msg = Vec::new();
1198        codec::encode_string(&mut msg, "sign-2");
1199        msg.push(TAG_SIGN_RAW_REQ);
1200        msg.push(0); // v1
1201        codec::encode_var_bytes(&mut msg, &[0xBB; 32]); // publicKey
1202        msg.extend_from_slice(b"raw-bytes");
1203
1204        match api.handle_message(&msg, TEST_APP) {
1205            HostApiOutcome::NeedsSign {
1206                request_id,
1207                request_tag,
1208                public_key,
1209                payload,
1210            } => {
1211                assert_eq!(request_id, "sign-2");
1212                assert_eq!(request_tag, TAG_SIGN_RAW_REQ);
1213                assert_eq!(public_key, vec![0xBB; 32]);
1214                assert_eq!(payload, b"raw-bytes");
1215            }
1216            _ => panic!("expected NeedsSign"),
1217        }
1218    }
1219
1220    #[test]
1221    fn logging_redacts_sign_payload_requests() {
1222        let mut api = HostApi::new();
1223        let request_id = "req-id-secret-123";
1224        let payload_secret = "payload-secret-456";
1225        let pubkey_secret_hex = "abababab";
1226        let mut msg = Vec::new();
1227        codec::encode_string(&mut msg, request_id);
1228        msg.push(TAG_SIGN_PAYLOAD_REQ);
1229        msg.push(0); // v1
1230        codec::encode_var_bytes(&mut msg, &[0xAB; 32]);
1231        msg.extend_from_slice(payload_secret.as_bytes());
1232
1233        let (outcome, logs) = capture_logs(|| api.handle_message(&msg, TEST_APP));
1234
1235        match outcome {
1236            HostApiOutcome::NeedsSign {
1237                request_id: actual_request_id,
1238                request_tag,
1239                public_key,
1240                payload,
1241            } => {
1242                assert_eq!(actual_request_id, request_id);
1243                assert_eq!(request_tag, TAG_SIGN_PAYLOAD_REQ);
1244                assert_eq!(public_key, vec![0xAB; 32]);
1245                assert_eq!(payload, payload_secret.as_bytes());
1246            }
1247            other => panic!("expected NeedsSign, got {}", outcome_name(&other)),
1248        }
1249
1250        assert_logs_contain(&logs, "request: kind=sign_payload (tag=36)");
1251        assert_logs_contain(&logs, "sign_payload request (pubkey=32 bytes)");
1252        assert_logs_do_not_contain(&logs, request_id);
1253        assert_logs_do_not_contain(&logs, payload_secret);
1254        assert_logs_do_not_contain(&logs, pubkey_secret_hex);
1255    }
1256
1257    #[test]
1258    fn logging_redacts_sign_raw_requests() {
1259        let mut api = HostApi::new();
1260        let request_id = "req-id-secret-raw";
1261        let raw_secret = "raw-secret-789";
1262        let mut msg = Vec::new();
1263        codec::encode_string(&mut msg, request_id);
1264        msg.push(TAG_SIGN_RAW_REQ);
1265        msg.push(0); // v1
1266        codec::encode_var_bytes(&mut msg, &[0xCD; 32]);
1267        msg.extend_from_slice(raw_secret.as_bytes());
1268
1269        let (outcome, logs) = capture_logs(|| api.handle_message(&msg, TEST_APP));
1270
1271        match outcome {
1272            HostApiOutcome::NeedsSign {
1273                request_id: actual_request_id,
1274                request_tag,
1275                public_key,
1276                payload,
1277            } => {
1278                assert_eq!(actual_request_id, request_id);
1279                assert_eq!(request_tag, TAG_SIGN_RAW_REQ);
1280                assert_eq!(public_key, vec![0xCD; 32]);
1281                assert_eq!(payload, raw_secret.as_bytes());
1282            }
1283            other => panic!("expected NeedsSign, got {}", outcome_name(&other)),
1284        }
1285
1286        assert_logs_contain(&logs, "request: kind=sign_raw (tag=34)");
1287        assert_logs_contain(&logs, "sign_raw request (pubkey=32 bytes)");
1288        assert_logs_do_not_contain(&logs, request_id);
1289        assert_logs_do_not_contain(&logs, raw_secret);
1290    }
1291
1292    #[test]
1293    fn logging_redacts_navigation_requests() {
1294        let mut api = HostApi::new();
1295        let request_id = "req-id-secret-nav";
1296        let url = "https://example.com/callback?token=nav-secret-123";
1297        let req = make_navigate_request(request_id, url);
1298
1299        let (outcome, logs) = capture_logs(|| api.handle_message(&req, TEST_APP));
1300
1301        match outcome {
1302            HostApiOutcome::NeedsNavigate {
1303                request_id: actual_request_id,
1304                url: actual_url,
1305            } => {
1306                assert_eq!(actual_request_id, request_id);
1307                assert_eq!(actual_url, url);
1308            }
1309            other => panic!("expected NeedsNavigate, got {}", outcome_name(&other)),
1310        }
1311
1312        assert_logs_contain(&logs, "request: kind=navigate_to (tag=6)");
1313        assert_logs_contain(&logs, "navigate_to request");
1314        assert_logs_do_not_contain(&logs, request_id);
1315        assert_logs_do_not_contain(&logs, "nav-secret-123");
1316    }
1317
1318    #[test]
1319    fn logging_redacts_local_storage_write_requests() {
1320        let mut api = HostApi::new();
1321        let request_id = "req-id-secret-storage";
1322        let key = "storage-secret-key";
1323        let value = b"storage-secret-value";
1324        let req = make_storage_write(request_id, key, value);
1325
1326        let (outcome, logs) = capture_logs(|| api.handle_message(&req, TEST_APP));
1327        let resp = expect_response(outcome);
1328
1329        let mut r = codec::Reader::new(&resp);
1330        assert_eq!(r.read_string().unwrap(), request_id);
1331        assert_eq!(r.read_u8().unwrap(), TAG_LOCAL_STORAGE_WRITE_RESP);
1332        assert_eq!(r.read_u8().unwrap(), 0);
1333        assert_eq!(r.read_u8().unwrap(), 0);
1334
1335        assert_logs_contain(&logs, "request: kind=local_storage_write (tag=14)");
1336        assert_logs_do_not_contain(&logs, request_id);
1337        assert_logs_do_not_contain(&logs, key);
1338        assert_logs_do_not_contain(&logs, "storage-secret-value");
1339    }
1340
1341    #[test]
1342    fn logging_redacts_feature_supported_requests() {
1343        let mut api = HostApi::new();
1344        let utf8_secret = "feature-secret-utf8";
1345        let utf8_req =
1346            make_feature_supported_request("req-id-feature-utf8", utf8_secret.as_bytes());
1347
1348        let (utf8_outcome, utf8_logs) = capture_logs(|| api.handle_message(&utf8_req, TEST_APP));
1349        let utf8_resp = expect_response(utf8_outcome);
1350        let mut utf8_reader = codec::Reader::new(&utf8_resp);
1351        utf8_reader.read_string().unwrap();
1352        assert_eq!(utf8_reader.read_u8().unwrap(), TAG_FEATURE_SUPPORTED_RESP);
1353        utf8_reader.read_u8().unwrap();
1354        utf8_reader.read_u8().unwrap();
1355        assert_eq!(utf8_reader.read_u8().unwrap(), 0);
1356        assert_logs_contain(&utf8_logs, "request: kind=feature_supported (tag=2)");
1357        assert_logs_contain(&utf8_logs, "feature_supported: feature=utf8_other -> false");
1358        assert_logs_do_not_contain(&utf8_logs, utf8_secret);
1359
1360        let binary_req =
1361            make_feature_supported_request("req-id-feature-binary", &[0, 0xde, 0xad, 0xbe, 0xef]);
1362        let (binary_outcome, binary_logs) =
1363            capture_logs(|| api.handle_message(&binary_req, TEST_APP));
1364        let binary_resp = expect_response(binary_outcome);
1365        let mut binary_reader = codec::Reader::new(&binary_resp);
1366        binary_reader.read_string().unwrap();
1367        assert_eq!(binary_reader.read_u8().unwrap(), TAG_FEATURE_SUPPORTED_RESP);
1368        binary_reader.read_u8().unwrap();
1369        binary_reader.read_u8().unwrap();
1370        assert_eq!(binary_reader.read_u8().unwrap(), 0);
1371        assert_logs_contain(
1372            &binary_logs,
1373            "feature_supported: feature=binary_chain -> false",
1374        );
1375        assert_logs_do_not_contain(&binary_logs, "deadbeef");
1376    }
1377
1378    #[test]
1379    fn logging_redacts_jsonrpc_requests() {
1380        let mut api = HostApi::new();
1381        let request_id = "req-id-secret-jsonrpc";
1382        let json = r#"{"jsonrpc":"2.0","id":1,"method":"rpc-secret-method","params":["jsonrpc-secret-param"]}"#;
1383        let req = make_jsonrpc_request(request_id, TAG_JSONRPC_SEND_REQ, json);
1384
1385        let (outcome, logs) = capture_logs(|| api.handle_message(&req, TEST_APP));
1386
1387        match outcome {
1388            HostApiOutcome::NeedsChainQuery {
1389                request_id: actual_request_id,
1390                method,
1391                params,
1392            } => {
1393                assert_eq!(actual_request_id, request_id);
1394                assert_eq!(method, "rpc-secret-method");
1395                assert_eq!(params, serde_json::json!(["jsonrpc-secret-param"]));
1396            }
1397            other => panic!("expected NeedsChainQuery, got {}", outcome_name(&other)),
1398        }
1399
1400        assert_logs_contain(&logs, "request: kind=jsonrpc_send (tag=70)");
1401        assert_logs_contain(&logs, "jsonrpc_send request");
1402        assert_logs_do_not_contain(&logs, request_id);
1403        assert_logs_do_not_contain(&logs, "rpc-secret-method");
1404        assert_logs_do_not_contain(&logs, "jsonrpc-secret-param");
1405    }
1406
1407    #[test]
1408    fn logging_redacts_jsonrpc_subscribe_requests() {
1409        let mut api = HostApi::new();
1410        let request_id = "req-id-secret-jsonrpc-sub";
1411        let json = r#"{"jsonrpc":"2.0","id":1,"method":"rpc-secret-subscribe","params":["jsonrpc-secret-sub-param"]}"#;
1412        let req = make_jsonrpc_request(request_id, TAG_JSONRPC_SUB_START, json);
1413
1414        let (outcome, logs) = capture_logs(|| api.handle_message(&req, TEST_APP));
1415
1416        match outcome {
1417            HostApiOutcome::NeedsChainSubscription {
1418                request_id: actual_request_id,
1419                method,
1420                params,
1421            } => {
1422                assert_eq!(actual_request_id, request_id);
1423                assert_eq!(method, "rpc-secret-subscribe");
1424                assert_eq!(params, serde_json::json!(["jsonrpc-secret-sub-param"]));
1425            }
1426            other => panic!(
1427                "expected NeedsChainSubscription, got {}",
1428                outcome_name(&other)
1429            ),
1430        }
1431
1432        assert_logs_contain(&logs, "request: kind=jsonrpc_subscribe_start (tag=72)");
1433        assert_logs_contain(&logs, "jsonrpc_subscribe_start request");
1434        assert_logs_do_not_contain(&logs, request_id);
1435        assert_logs_do_not_contain(&logs, "rpc-secret-subscribe");
1436        assert_logs_do_not_contain(&logs, "jsonrpc-secret-sub-param");
1437    }
1438
1439    #[test]
1440    fn logging_redacts_decode_failures() {
1441        let mut api = HostApi::new();
1442        let malformed = b"decode-secret-123";
1443
1444        let (outcome, logs) = capture_logs(|| api.handle_message(malformed, TEST_APP));
1445
1446        expect_silent(outcome);
1447        assert_logs_contain(&logs, "failed to decode message: kind=");
1448        assert_logs_do_not_contain(&logs, "decode-secret-123");
1449    }
1450}