Skip to main content

futu_gateway_core/
handlers_sys.rs

1// 系统业务处理器 — GetUserInfo / GetDelayStatistics / Verification / TestCmd / RemoteCmd
2
3use std::sync::Arc;
4
5use async_trait::async_trait;
6
7use futu_cache::user_profile::{UserProfile, UserProfileCache};
8use futu_core::error::FutuError;
9use futu_core::proto_id;
10use futu_server::conn::IncomingRequest;
11use futu_server::router::{RequestHandler, RequestRouter};
12
13use crate::bridge::{BrokerRuntime, CacheBundle, GatewayBridge};
14
15mod commands;
16
17use commands::{RemoteCmdHandler, TestCmdHandler};
18
19const DEFAULT_USER_INFO_FLAG: i32 = 0xFFFFF7FFu32 as i32;
20const USER_INFO_FIELD_BASIC: i32 = 1;
21const USER_INFO_FIELD_API: i32 = 2;
22const USER_INFO_FIELD_QOT_RIGHT: i32 = 4;
23const USER_INFO_FIELD_DISCLAIMER: i32 = 8;
24const USER_INFO_FIELD_UPDATE: i32 = 16;
25
26/// 注册系统处理器
27pub fn register_handlers(router: &Arc<RequestRouter>, bridge: &GatewayBridge) {
28    register_handlers_inner(router, bridge, None);
29}
30
31/// 注册系统处理器,并把 daemon-owned shutdown signal 注入给 TestCmd。
32///
33/// C++ `APIServer_TestCmd.cpp` 的 `exit` 先回成功包,随后发
34/// `API_OMEvent_Command / GTW_Command_Exit` 给网关主事件循环。Rust 生产路径
35/// 等价接入 `futu-opend` phase4 的统一 shutdown channel,避免业务 handler
36/// 直接 `process::exit` 绕过 surface graceful shutdown。
37pub fn register_handlers_with_shutdown(
38    router: &Arc<RequestRouter>,
39    bridge: &GatewayBridge,
40    shutdown_tx: tokio::sync::watch::Sender<bool>,
41) {
42    register_handlers_inner(router, bridge, Some(shutdown_tx));
43}
44
45fn register_handlers_inner(
46    router: &Arc<RequestRouter>,
47    bridge: &GatewayBridge,
48    shutdown_tx: Option<tokio::sync::watch::Sender<bool>>,
49) {
50    let caches = bridge.caches();
51    router.register(
52        proto_id::GET_USER_INFO,
53        Arc::new(GetUserInfoHandler {
54            caches: caches.clone(),
55            broker_runtime: bridge.broker_runtime().clone(),
56        }),
57    );
58    router.register(
59        proto_id::GET_DELAY_STATISTICS,
60        Arc::new(GetDelayStatisticsHandler),
61    );
62    router.register(proto_id::VERIFICATION, Arc::new(VerificationHandler));
63    // v1.4.106 codex 1110 F7 [P2]: TestCmd / RemoteCmd 拿到 backend + qot_right_cache
64    // 来真发 CMD6024 (request_qot_right) — silent-success 反模式修复。
65    router.register(
66        proto_id::TEST_CMD,
67        Arc::new(TestCmdHandler {
68            caches: caches.clone(),
69            broker_runtime: bridge.broker_runtime().clone(),
70            shutdown_tx,
71        }),
72    );
73    router.register(
74        proto_id::REMOTE_CMD,
75        Arc::new(RemoteCmdHandler {
76            caches: caches.clone(),
77            broker_runtime: bridge.broker_runtime().clone(),
78        }),
79    );
80
81    // v1.4.98 T2-8 (mobile-source-audit Phase 2): NN+MM token 状态查询 (cmd 1326).
82    router.register(
83        proto_id::GET_TOKEN_STATE,
84        Arc::new(GetTokenStateHandler {
85            caches: caches.clone(),
86            broker_runtime: bridge.broker_runtime().clone(),
87        }),
88    );
89
90    tracing::debug!("system handlers registered");
91}
92
93// ===== GetUserInfo (1005) =====
94// C++ 从多个后端聚合查询(Profile、QotRight、Disclaimer、Update)。
95// Rust: user_id 从登录缓存获取,行情权限从 CMD 6024 响应缓存获取。
96struct GetUserInfoHandler {
97    caches: CacheBundle,
98    broker_runtime: BrokerRuntime,
99}
100
101#[async_trait]
102impl RequestHandler for GetUserInfoHandler {
103    async fn handle(&self, _conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
104        let req: futu_proto::get_user_info::Request =
105            match crate::handlers_shared::decode_request_or_error(
106                request.body.as_ref(),
107                "sys",
108                "GetUserInfo",
109            ) {
110                Ok(req) => req,
111                Err(resp) => return Some(resp),
112            };
113        let flag = req.c2s.flag.unwrap_or(DEFAULT_USER_INFO_FLAG);
114
115        let include_basic = has_user_info_field(flag, USER_INFO_FIELD_BASIC);
116        let include_api = has_user_info_field(flag, USER_INFO_FIELD_API);
117        let include_qot_right = has_user_info_field(flag, USER_INFO_FIELD_QOT_RIGHT);
118        let include_disclaimer = has_user_info_field(flag, USER_INFO_FIELD_DISCLAIMER);
119        let include_update = has_user_info_field(flag, USER_INFO_FIELD_UPDATE);
120
121        let login_state = self.caches.login_cache.get_login_state();
122        let user_id = login_state.as_ref().map(|s| s.user_id as i64).unwrap_or(0);
123        let user_id_u64 = u64::try_from(user_id).unwrap_or(0);
124        let user_attribution = login_state.as_ref().and_then(|s| s.user_attribution);
125        if user_id_u64 != 0
126            && include_basic
127            && let Err(e) = refresh_user_profile_cache(
128                &self.broker_runtime,
129                &self.caches.user_profile_cache,
130                user_id_u64,
131            )
132            .await
133            && !matches!(e, FutuError::Timeout)
134        {
135            return Some(crate::handlers_shared::make_error_response(
136                -1,
137                &format!("GetUserInfo QueryUserProfile failed: {e}"),
138            ));
139        }
140        let profile = self.caches.user_profile_cache.get(user_id_u64);
141        let nick_name = profile
142            .as_ref()
143            .map(|profile| profile.nick_name.clone())
144            .unwrap_or_default();
145        let avatar_url = profile
146            .as_ref()
147            .map(|profile| profile.avatar_url.clone())
148            .unwrap_or_default();
149
150        // v1.4.106 codex 1217 F4 [P2] (5月1日): 不再 silent 暴露 default quota / Unknown 权限.
151        //
152        // freshness=Fresh → 返实值; Pending/Unknown/Failed → 全部字段 0/false + ret_msg
153        // 提示客户端 "数据未就绪". Stale → 保留旧 data 但 ret_msg 标 stale.
154        let qr = self.caches.qot_right_cache.get();
155        let freshness = self.caches.qot_right_cache.freshness();
156        let meta = self.caches.qot_right_cache.meta_snapshot();
157        let needs_qot_cache = include_api || include_qot_right;
158        let (use_real, ret_msg_v) = match &freshness {
159            futu_cache::qot_right::QotRightFreshness::Fresh => (true, None),
160            futu_cache::qot_right::QotRightFreshness::Stale => (
161                true,
162                needs_qot_cache.then(|| format!(
163                    "qot_right cache stale (login_epoch={}, backend_generation={}); refresh in flight",
164                    meta.login_epoch, meta.backend_generation,
165                )),
166            ),
167            futu_cache::qot_right::QotRightFreshness::Pending => (
168                false,
169                needs_qot_cache.then(|| {
170                    "qot_right refresh pending (CMD6032 in flight, retry shortly)".to_string()
171                }),
172            ),
173            futu_cache::qot_right::QotRightFreshness::Unknown => (
174                false,
175                needs_qot_cache.then(|| {
176                    "qot_right cache uninitialized; daemon may still be booting".to_string()
177                }),
178            ),
179            futu_cache::qot_right::QotRightFreshness::Failed { error, since_ms } => (
180                false,
181                needs_qot_cache.then(|| format!(
182                    "qot_right refresh failed: {} (since_ms={}); call request_highest_quote_right to retry",
183                    error, since_ms,
184                )),
185            ),
186        };
187        let pick_r = |v: i32| if use_real { v } else { 0 };
188        let pick_b = |v: bool| if use_real { v } else { false };
189        let pick_q = |v: i32| if use_real { v } else { 0 };
190
191        let s2c = futu_proto::get_user_info::S2c {
192            nick_name: include_basic.then_some(nick_name),
193            avatar_url: include_basic.then_some(avatar_url),
194            api_level: None,
195            hk_qot_right: include_qot_right.then_some(pick_r(qr.hk_qot_right)),
196            us_qot_right: include_qot_right.then_some(pick_r(qr.api_us_qot_right)),
197            // Ref: FutuOpenD/Src/APIServer/Business/APIServer_GetUserInfo.cpp:425-429.
198            // C++ fills shQotRight/szQotRight, not legacy cnQotRight.
199            cn_qot_right: None,
200            is_need_agree_disclaimer: include_disclaimer.then_some(false),
201            user_id: include_basic.then_some(user_id),
202            update_type: include_update.then_some(0), // UpdateType_None
203            web_key: None,
204            web_jump_url_head: None,
205            hk_option_qot_right: include_qot_right.then_some(pick_r(qr.hk_option_qot_right)),
206            has_us_option_qot_right: include_qot_right
207                .then_some(pick_b(qr.has_us_option_qot_right)),
208            hk_future_qot_right: include_qot_right.then_some(pick_r(qr.hk_future_qot_right)),
209            sub_quota: include_api.then_some(pick_q(qr.sub_quota)),
210            history_kl_quota: include_api.then_some(pick_q(qr.history_kl_quota)),
211            // Ref: FutuOpenD/Src/APIServer/Business/APIServer_GetUserInfo.cpp:435-436.
212            // The aggregate US future field is commented out in C++; newer per-exchange
213            // fields are filled below.
214            us_future_qot_right: None,
215            us_option_qot_right: include_qot_right.then_some(pick_r(qr.us_option_qot_right)),
216            user_attribution: (include_basic || user_id_u64 == 0)
217                .then_some(user_attribution)
218                .flatten(),
219            update_whats_new: None,
220            us_index_qot_right: include_qot_right.then_some(pick_r(qr.us_index_qot_right)),
221            us_otc_qot_right: include_qot_right.then_some(pick_r(qr.us_otc_qot_right)),
222            us_cme_future_qot_right: include_qot_right
223                .then_some(pick_r(qr.us_cme_future_qot_right)),
224            us_cbot_future_qot_right: include_qot_right
225                .then_some(pick_r(qr.us_cbot_future_qot_right)),
226            us_nymex_future_qot_right: include_qot_right
227                .then_some(pick_r(qr.us_nymex_future_qot_right)),
228            us_comex_future_qot_right: include_qot_right
229                .then_some(pick_r(qr.us_comex_future_qot_right)),
230            us_cboe_future_qot_right: include_qot_right
231                .then_some(pick_r(qr.us_cboe_future_qot_right)),
232            sg_future_qot_right: include_qot_right.then_some(pick_r(qr.sg_future_qot_right)),
233            jp_future_qot_right: include_qot_right.then_some(pick_r(qr.jp_future_qot_right)),
234            is_app_nn_or_mm: include_basic.then_some(true), // NN 版本
235            sh_qot_right: include_qot_right.then_some(pick_r(qr.sh_qot_right)),
236            sz_qot_right: include_qot_right.then_some(pick_r(qr.sz_qot_right)),
237            extra: None,
238            cc_qot_right: include_qot_right.then_some(pick_r(qr.cc_qot_right)),
239            sg_stock_qot_right: include_qot_right.then_some(pick_r(qr.sg_stock_qot_right)),
240            my_stock_qot_right: include_qot_right.then_some(pick_r(qr.my_stock_qot_right)),
241            jp_stock_qot_right: include_qot_right.then_some(pick_r(qr.jp_stock_qot_right)),
242        };
243
244        let resp = futu_proto::get_user_info::Response {
245            ret_type: 0,
246            ret_msg: ret_msg_v,
247            err_code: None,
248            s2c: Some(s2c),
249        };
250        Some(prost::Message::encode_to_vec(&resp))
251    }
252}
253
254fn has_user_info_field(flag: i32, field: i32) -> bool {
255    (flag & field) != 0
256}
257
258async fn refresh_user_profile_cache(
259    broker_runtime: &BrokerRuntime,
260    user_profile_cache: &UserProfileCache,
261    user_id: u64,
262) -> Result<(), FutuError> {
263    let Some(backend) = broker_runtime.platform_backend() else {
264        return Ok(());
265    };
266
267    match futu_backend::user_profile::query_user_profile(&backend, user_id).await {
268        Ok(profile) => {
269            user_profile_cache.set(UserProfile {
270                user_id: profile.user_id,
271                nick_name: profile.nick_name,
272                avatar_url: profile.avatar_url,
273            });
274            Ok(())
275        }
276        Err(e) => {
277            tracing::warn!(
278                user_id,
279                error = %e,
280                "CMD7506 QueryUserProfile failed; using cached profile if available"
281            );
282            Err(e)
283        }
284    }
285}
286
287// ===== GetDelayStatistics (1007) =====
288// C++ 从本地 INNData_ProtoDelay 收集延迟统计数据。
289// Ref: FutuOpenD/Src/APIServer/Business/APIServer_GetDelayStatistics.cpp:71
290// Rust delay_stats 接入 ReqReply + QOT push 聚合计数 + PlaceOrder once-detail。
291struct GetDelayStatisticsHandler;
292
293#[async_trait]
294impl RequestHandler for GetDelayStatisticsHandler {
295    async fn handle(&self, _conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
296        let req: futu_proto::get_delay_statistics::Request =
297            match crate::handlers_shared::decode_request_or_error(
298                request.body.as_ref(),
299                "sys",
300                "GetDelayStatistics",
301            ) {
302                Ok(req) => req,
303                Err(resp) => return Some(resp),
304            };
305
306        let include_req_reply = req
307            .c2s
308            .type_list
309            .contains(&(futu_proto::get_delay_statistics::DelayStatisticsType::ReqReply as i32));
310        let include_qot_push = req
311            .c2s
312            .type_list
313            .contains(&(futu_proto::get_delay_statistics::DelayStatisticsType::QotPush as i32));
314        let include_place_order = req
315            .c2s
316            .type_list
317            .contains(&(futu_proto::get_delay_statistics::DelayStatisticsType::PlaceOrder as i32));
318
319        let s2c = futu_proto::get_delay_statistics::S2c {
320            qot_push_statistics_list: if include_qot_push {
321                futu_core::delay_stats::snapshot_qot_push_statistics(
322                    req.c2s.qot_push_stage.unwrap_or(0),
323                    &req.c2s.segment_list,
324                )
325                .into_iter()
326                .map(|item| futu_proto::get_delay_statistics::DelayStatistics {
327                    qot_push_type: item.qot_push_type,
328                    item_list: item
329                        .item_list
330                        .into_iter()
331                        .map(
332                            |bucket| futu_proto::get_delay_statistics::DelayStatisticsItem {
333                                begin: bucket.begin,
334                                end: bucket.end,
335                                count: bucket.count,
336                                proportion: bucket.proportion,
337                                cumulative_ratio: bucket.cumulative_ratio,
338                            },
339                        )
340                        .collect(),
341                    delay_avg: item.delay_avg_ms,
342                    count: item.count,
343                })
344                .collect()
345            } else {
346                vec![]
347            },
348            req_reply_statistics_list: if include_req_reply {
349                futu_core::delay_stats::snapshot_req_reply_statistics()
350                    .into_iter()
351                    .map(
352                        |item| futu_proto::get_delay_statistics::ReqReplyStatisticsItem {
353                            proto_id: item.proto_id as i32,
354                            count: item.count as i32,
355                            total_cost_avg: item.total_cost_avg_ms,
356                            open_d_cost_avg: item.open_d_cost_avg_ms,
357                            net_delay_avg: item.net_delay_avg_ms,
358                            is_local_reply: item.is_local_reply,
359                        },
360                    )
361                    .collect()
362            } else {
363                vec![]
364            },
365            place_order_statistics_list: if include_place_order {
366                futu_core::delay_stats::snapshot_place_order_statistics()
367                    .into_iter()
368                    .map(
369                        |item| futu_proto::get_delay_statistics::PlaceOrderStatisticsItem {
370                            order_id: item.order_id,
371                            total_cost: item.total_cost_ms,
372                            open_d_cost: item.open_d_cost_ms,
373                            net_delay: item.net_delay_ms,
374                            update_cost: item.update_cost_ms,
375                        },
376                    )
377                    .collect()
378            } else {
379                vec![]
380            },
381        };
382
383        let resp = futu_proto::get_delay_statistics::Response {
384            ret_type: 0,
385            ret_msg: None,
386            err_code: None,
387            s2c: Some(s2c),
388        };
389        Some(prost::Message::encode_to_vec(&resp))
390    }
391}
392
393// ===== Verification (1006) =====
394// C++ 处理图形/短信验证码的请求和验证。
395// Ref: FutuOpenD/Src/APIServer/Business/APIServer_Verification.cpp:191
396// Rust 尚未接入 INNLoginCenter 验证码状态机;这里必须 fail-loud,避免
397// 用户/SDK 误以为验证码请求或输入已生效。
398struct VerificationHandler;
399
400#[async_trait]
401impl RequestHandler for VerificationHandler {
402    async fn handle(&self, _conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
403        let _req: futu_proto::verification::Request =
404            match crate::handlers_shared::decode_request_or_error(
405                request.body.as_ref(),
406                "sys",
407                "Verification",
408            ) {
409                Ok(req) => req,
410                Err(resp) => return Some(resp),
411            };
412
413        let resp = futu_proto::verification::Response {
414            ret_type: -1,
415            ret_msg: Some(
416                "Verification requires the C++ INNLoginCenter captcha/SMS flow; \
417                 Rust OpenD does not implement this flow yet"
418                    .to_string(),
419            ),
420            err_code: None,
421            s2c: None,
422        };
423        Some(prost::Message::encode_to_vec(&resp))
424    }
425}
426
427// ===== GetTokenState (1326) — v1.4.98 T2-8 (mobile-source-audit Phase 2) =====
428//
429// 来源: moomoo `Moomoo/Src/FTBase/FTBaseBusiness/Settings/Controller/Token/FuTuToken.cpp:19`
430// cmd 1326 `CS_CMDID_NewToken_GetStateInfo` + proto
431// `moomoo/.../Proto/futu_token.proto::GetTokenListRequest/Response`.
432//
433// **Use case**: pitfall #15 实证 "moomoo token = 富途令牌的海外版本", 但 daemon
434// 之前没暴露查询 endpoint. 用户 unlock-trade 失败 -20011 时第一线诊断 — 调
435// `/api/token-state` 查 NN/MM 双系 token 绑定/启用情况, 决定 TOTP secret 该来
436// 自哪个 app.
437//
438// **三铁律自检** (per pitfall #51 v1.4.94 mobile-driven extension 修订):
439// - 来源真实: ✅ 引用 moomoo proto file:line + cmd_id
440// - OpenD 兼容: ✅ ADD-only (`/api/token-state` 新 endpoint, 不动现有)
441// - 失败 loud: ✅ backend 拒绝时返清晰 ret_type + msg
442//
443// **Channel**: platform TCP (无 encryption, 4 个 uint32 简单 response).
444// **真机 verify**: 标 UNVERIFIED (per pitfall #57) — 保留为
445// real-machine-required,跨地区 sim 账户验证有正证据后再升 confidence.
446struct GetTokenStateHandler {
447    caches: CacheBundle,
448    broker_runtime: BrokerRuntime,
449}
450
451#[async_trait]
452impl RequestHandler for GetTokenStateHandler {
453    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
454        // Decode daemon-side wire envelope (DaemonGetTokenStateReq)
455        let req: futu_backend::proto_internal::futu_token_state::DaemonGetTokenStateReq =
456            match prost::Message::decode(request.body.as_ref()) {
457                Ok(r) => r,
458                Err(e) => {
459                    tracing::warn!(conn_id, error = %e, "GetTokenState: decode req failed");
460                    return Some(crate::handlers_shared::make_error_response(
461                        -1,
462                        "invalid GetTokenState request body",
463                    ));
464                }
465            };
466
467        // uid 从 login_cache 取 (caller 不需要传 — daemon 单租户语义)
468        let user_id = match self.caches.login_cache.get_login_state() {
469            Some(s) => s.user_id as u64,
470            None => {
471                return Some(crate::handlers_shared::make_error_response(
472                    -1,
473                    "GetTokenState: not logged in (login_cache empty). \
474                     Run f3clogin via futu-opend before querying token state.",
475                ));
476            }
477        };
478
479        let app_id_str = req.c2s.app_id.unwrap_or_else(|| "all".to_string());
480        let app_id = if app_id_str.is_empty() {
481            "all".to_string()
482        } else {
483            app_id_str
484        };
485
486        let backend = match self.broker_runtime.platform_backend() {
487            Some(b) => b,
488            None => {
489                tracing::warn!(conn_id, "GetTokenState: no backend connection");
490                return Some(crate::handlers_shared::make_error_response(
491                    -1,
492                    "no backend connection (try again after backend reconnect)",
493                ));
494            }
495        };
496
497        // Build moomoo native GetTokenListRequest (uid + app_id bytes)
498        let backend_req = futu_backend::proto_internal::futu_token_state::GetTokenListRequest {
499            uid: Some(user_id),
500            app_id: Some(app_id.as_bytes().to_vec()),
501        };
502        let body = prost::Message::encode_to_vec(&backend_req);
503
504        tracing::debug!(
505            conn_id,
506            uid = user_id,
507            app_id = %app_id,
508            "sending CMD1326 GetTokenListRequest"
509        );
510
511        let resp_frame = match backend
512            .request(proto_id::GET_TOKEN_STATE as u16, body)
513            .await
514        {
515            Ok(f) => f,
516            Err(e) => {
517                tracing::error!(conn_id, error = %e, "CMD1326 backend request failed");
518                return Some(crate::handlers_shared::make_error_response(
519                    -1,
520                    &format!("GetTokenState: backend cmd 1326 failed: {e}"),
521                ));
522            }
523        };
524
525        let backend_rsp: futu_backend::proto_internal::futu_token_state::GetTokenListResponse =
526            match crate::handlers_shared::decode_backend_proto(&resp_frame.body) {
527                Ok(r) => r,
528                Err(e) => {
529                    tracing::error!(conn_id, error = %e, "CMD1326 decode response failed");
530                    return Some(crate::handlers_shared::make_error_response(
531                        -1,
532                        "GetTokenState: failed to decode backend response",
533                    ));
534                }
535            };
536
537        // v1.4.106 codex 1110 F6 [P2] 修:err_code 缺失不能当成功
538        //
539        // 老代码 `err_code.unwrap_or(0)` 把 backend response 的 err_code=None 当成功。
540        // 实际:backend envelope 异常 / 空 default response decode 出来 err_code 为 None
541        // 时,handler 会把 4 个 token 状态字段(nn_token_enable / nn_token_bind /
542        // mm_token_enable / mm_token_bind,全 None)原样塞进 ret_type=0 响应。
543        // unlock-trade 诊断如果依赖 token-state,会被引导成 "token 未绑定 / 未启用"
544        // 假象,而不是 "查询失败" 真相。
545        //
546        // 严格成功条件:err_code == Some(0)。
547        let err_code = backend_rsp.err_code;
548        if err_code != Some(0) {
549            let err_msg = backend_rsp.err_msg.as_deref().unwrap_or("");
550            let detail = match err_code {
551                Some(c) => format!("backend cmd 1326 err: {err_msg} (code={c})"),
552                None => format!(
553                    "backend cmd 1326 response missing err_code (raw err_msg='{err_msg}'); cannot determine token state"
554                ),
555            };
556            tracing::warn!(
557                conn_id,
558                err_code = ?err_code,
559                err_msg,
560                "CMD1326 backend response not Some(0): refusing silent success"
561            );
562            return Some(prost::Message::encode_to_vec(
563                &futu_backend::proto_internal::futu_token_state::DaemonGetTokenStateRsp {
564                    ret_type: -1,
565                    ret_msg: Some(detail),
566                    err_code,
567                    s2c: None,
568                },
569            ));
570        }
571
572        // 成功响应中四个 token 状态字段全缺 → 视作 partial/unknown,不静默成功
573        let all_token_fields_missing = backend_rsp.nn_token_enable.is_none()
574            && backend_rsp.nn_token_bind.is_none()
575            && backend_rsp.mm_token_enable.is_none()
576            && backend_rsp.mm_token_bind.is_none();
577        if all_token_fields_missing {
578            tracing::warn!(
579                conn_id,
580                "CMD1326 backend success (err_code=0) but all 4 token state fields missing — refusing silent ret_type=0"
581            );
582            return Some(prost::Message::encode_to_vec(
583                &futu_backend::proto_internal::futu_token_state::DaemonGetTokenStateRsp {
584                    ret_type: -1,
585                    ret_msg: Some(
586                        "backend cmd 1326 success but token state fields all missing; cannot return reliable token-state".to_string(),
587                    ),
588                    err_code: Some(0),
589                    s2c: None,
590                },
591            ));
592        }
593
594        // Build daemon-side wire response (DaemonGetTokenStateRsp)
595        let daemon_rsp = futu_backend::proto_internal::futu_token_state::DaemonGetTokenStateRsp {
596            ret_type: 0,
597            ret_msg: None,
598            err_code: None,
599            s2c: Some(
600                futu_backend::proto_internal::futu_token_state::daemon_get_token_state_rsp::S2c {
601                    nn_token_enable: backend_rsp.nn_token_enable,
602                    nn_token_bind: backend_rsp.nn_token_bind,
603                    mm_token_enable: backend_rsp.mm_token_enable,
604                    mm_token_bind: backend_rsp.mm_token_bind,
605                },
606            ),
607        };
608        Some(prost::Message::encode_to_vec(&daemon_rsp))
609    }
610}