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